Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 ff647fba0a release 2026.08.23.04
The web UI can be read in Portuguese. The shell and shared components are fully
keyed, 581 keys across two complete catalogues, with the build failing on a
missing one. Plus an endpoint for spawning an agent, and spawned agents becoming
visible in both directions.

No schema change and no dependency change. db:push should report nothing, and the
plan says to stop and report it if it does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:51:50 +00:00
pastilhasandClaude Opus 5 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>
2026-08-23 05:43:49 +00:00
pastilhas 454a0f4244 hold officer-web at zero hardcoded strings
Phase 2 is complete: 555 -> 0. Verified the guard both ways -- exit 1 naming the
string and file when one is reintroduced, exit 0 once it is gone.

officerdev is still at 578 and stays out of ZEROED; that is phase 3.
2026-08-23 05:40:01 +00:00
pastilhas a48a7ff696 the last two shell strings were the composer's pre-filled prompt (2 -> 0)
CapabilityPage puts a suggestion in the chat composer, which the reader sees and
edits before sending -- user-facing, so translated. The hidden promptFrontmatter
beside it is NOT: that one instructs the agent and carries file paths. The scanner
never flagged it, correctly, because it opens with a tag rather than a capital.

officer-web is now at zero convertible strings.
2026-08-23 05:38:17 +00:00
pastilhas bde59eb74e activity, capabilities, chat history and the locale picker (21 -> 2)
The locale picker's labels are now AUTONYMS computed from Intl -- each language named
in its own language, so "Português europeu" reads as Portuguese whichever locale the
UI is in. That is the convention for a language picker and the useful behaviour:
somebody who cannot read the current language still has to find theirs.

CapabilityPage interpolated the capability KIND into six strings by lowercasing it in
code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now.

What is left is two strings, both deliberate: see the next commit message.
2026-08-23 05:37:04 +00:00
pastilhas 0b85d42197 jobs keyed (73 -> 21)
The three job detail panes and the jobs page. Status labels share one jobs.status.*
group across all three panes rather than three copies of "Running".

statusBadge in two of the panes is a module-level helper, not a component, so it
takes `t` as a parameter -- the alternative is calling a hook outside a component
body. ScriptJobDetail's version built "Failed (exit N)" by string concatenation;
that is a key with a parameter now.
2026-08-23 05:30:45 +00:00
pastilhas 2e846933e9 shortcut registry keyed (96 -> 73)
shortcuts.ts is data with no component around it, so it carries catalog keys and
ShortcutsModal resolves them -- same shape as the settings sections.

gen-platform-skill.ts also reads this registry to generate the agent-facing SKILL.md,
which docs:check validates. That doc stays in one language, so the generator resolves
against en-GB directly and throws if a key is missing rather than emitting a dot-path
into a file an agent will quote at somebody. The regenerated skill is byte-identical,
which is the check that keying the registry changed nothing an agent reads.
2026-08-23 05:26:43 +00:00
pastilhas 5e032898f8 integrations keyed, and the Google guide's foreign labels marked (163 -> 96)
Applies the rule added to docs/localization.md in 91b9bec9: a label quoted from
another product's interface is an identifier, so the prose around it is translated
and the label itself is left in English and NOT keyed.

Gives that decision a name in the code. <Untranslated reason="foreign-ui-label">
states it at each of the ~30 call sites, where whoever next edits the sentence will
see it, and gives the scanner something exact to skip -- without which labels that
MUST stay English count as debt forever and the area can never be held at zero. That
is the same trap as counting new Error() messages. `reason` is a closed union so a
second excuse has to be argued for rather than smuggled in as free text.

The sentences needed rephrasing, not just splitting. "Go to the [New Project] page."
decomposes into "Go to the" + link + "page." -- and the Portuguese value of that
second key is "." A key that holds a full stop is the fragmentation smell, so the
English now puts the label at a clause boundary ("Open the project creation page:"
then the link), which reads naturally in both languages and needs no node-level
interpolation. Worth knowing for phase 3: sentences with an inline link are the one
shape this catalog format cannot express, and rephrasing is the cheap way out.

Scanner: the existing SCREAMING_CONSTANT rule now allows . and - so Google's
client-secret prefix example GOCSPX-... falls under it. Same rule, wider class.
2026-08-23 05:24:32 +00:00
pastilhasandClaude Opus 5 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>
2026-08-23 05:18:14 +00:00
pastilhas b978e09327 authentication and calendar-sync passwords keyed (214 -> 163)
Authentication reaches zero -- sign-in, bootstrap, forgot and reset password. These
are the first words a new account reads, which is why the brief ranked them above
the larger screens.

DavAppPasswords had the third toLocaleDateString(undefined, ...) in the shell; it now
uses useFormat().date like the other two.

DebugPanel's three labels are keyed too. Worth knowing rather than acting on: that
panel is rendered behind showDebug, which Background.tsx hardcodes to false, so it
never appears. Keyed rather than deleted because removing it is not my call.
2026-08-23 05:15:22 +00:00
pastilhas 7f8455f1af user management keyed (267 -> 214)
Accounts, permissions and the create-user form. The confirm dialogs for removing an
account and resetting a password were multi-line JSX the scanner cannot see, so they
were converted on sight rather than from its list.

Cancel comes from ui.button.cancel in all four places it appears here.
2026-08-23 05:10:17 +00:00
pastilhasandClaude Opus 5 f6b9ddc877 a spawned session says who started it
The other half of making a spawned agent visible. The commander's pane now lists
its children; opening one gave no way back — no link to the conversation that owns
it, and nothing to tell it apart from a chat somebody had started themselves and
forgotten.

`parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from
the other end. So this needed no table and no write path: a commander's children
were recorded because deleting one has to take them with it, and that record
answers "who is my parent" for free. Nothing had asked.

`GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line
above the transcript linking back to the commander. Null for almost every session,
which is the normal case rather than a miss — a chat somebody opened has no parent
— so the bar renders nothing at all rather than saying so. Never fatal: a session
must open when this lookup will not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:09:28 +00:00
pastilhas 44f5246e38 server settings keyed (352 -> 267)
ServerSettings reaches zero: OCR, STT, TTS, SMTP, maintenance and the AI harnesses.

Two more hand-rolled plurals became real plural entries -- maintenance said
"commit" with a ternary on length === 1, and both read correctly at 0, 1 and 2 in
both locales now. The three Cancel buttons in AIHarnessesSection take ui.button.cancel
rather than adding a fourth app-level copy of the word, which is what phase 1 put it
there for.

Scanner: a single token carrying digits and punctuation is a name, not a sentence --
the OCR placeholder is the example model file Qwen2.5-VL-7B-Instruct-q4_k_m.gguf.
Prose has spaces, and requiring whitespace to be absent is what keeps this from
eating real one-word labels. Verified the delta is exactly that one string.

Note for the owner: another session has src/servers/api/sub-agents/router.ts in
flight with three type errors. Untouched by me and excluded from my gate; the full
tree will not typecheck clean until they land it.
2026-08-23 05:04:22 +00:00
pastilhasandClaude Opus 5 9b0376ec39 todo: close the spawning endpoint
Both modes verified against the live server, not reasoned about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:02:45 +00:00
pastilhasandClaude Opus 5 66234cd3e0 an endpoint for spawning an agent, so a caller that is not a chat can
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.

So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.

The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.

`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.

Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.

Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:02:29 +00:00
pastilhas ad8ed88519 profile settings keyed (425 -> 352)
ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the
model defaults.

Language names now come from Intl.DisplayNames rather than the catalog. The runtime
already knows "German" in English and "alemao" in Portuguese, so 28 names would have
been 56 hand-written entries that Intl gets right for free -- including for locales
not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays
hardcoded: which languages you may claim to speak is a product choice.

ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale --
the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key
toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now.

Scanner: an Authorization header value like "Bearer <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
2026-08-23 04:57:48 +00:00
pastilhasandClaude Opus 5 7ce5af88aa todo: close the invisible spawned agent
The endpoint, table and ingest all existed and nothing called them, so the table
read empty and the feature looked unbuilt. Ingest on read means an empty table was
itself the symptom: nobody had looked.

The proposed second fix — the loop echoing the transcript id — turned out
unnecessary. The ids are already in the loop's spawned record, which the endpoint
ingests, so they reach the UI without the loop narrating anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:55:11 +00:00
pastilhasandClaude Opus 5 6bfc1d38f7 a spawned agent is a link, not a rumour
The endpoint, the table and the ingest all existed and nothing had ever called
them — so `command_hq_spawned` sat empty and a spawned agent stayed invisible even
though its transcript was readable the whole time. It writes an ordinary transcript
under the project directory for its cwd, so `/chat/<id>` has always rendered it
live. What was missing was anybody being told the id existed: the job panel showed
its output as a text stream in a small box with no link, so finding the session
meant knowing to look.

Now the commander's own pane lists them, each a link to its transcript, with a dot
on the ones still running. Ingest happens on read, so the list is right without a
timer that can stop working quietly.

Cost is stated as a floor when any child did not report one — `$1.20+` and a count
of unknowns, never a confident total. A commander's line reading $0.28 for a night
that spent several dollars is the specific understatement this exists to stop.

Renders nothing when there are none. A commander that has not fired yet is the
normal case and does not need a box explaining that.

Closes todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:54:54 +00:00
pastilhas c10dae3e6e scanner: text inside code, kbd, pre and samp is code, not prose
ApiKeys.tsx documents the header to send as <code>Authorization: Bearer …</code>.
That is an identifier crossing a boundary on the doc's never-translate list, and
translating it would break the thing it documents. Those four elements exist to mark
content as code, so the scanner now honours them.

Verified the delta across both remaining areas is exactly that one string.
2026-08-23 04:47:05 +00:00
pastilhasandClaude Opus 5 41fffbebe9 todo: close delivering a turn to a named session — answered from both ends
SendMessage does agent-to-agent with notify_when_idle instead of a poll, proven
tonight when one session took over the localisation hand-off from the owner. The
nudger does backend-to-session on a schedule, which is how a commander now reports
into its own conversation rather than a job panel.

Kept open deliberately: spawning a NEW session from a non-chat caller. The harness
Agent tool does not close that — it is available to a chat agent and nothing else,
which is why a commander's loop still shells out to claude -p.

Also kept: 'a spawned agent is invisible in the interface'. That is a defect in
Officer's own UI with a one-echo fix, and deleting it because an affordance now
describes coordination is exactly what docs/affordances.md warns about — an
affordance describing a defect calcifies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:47:03 +00:00
pastilhas c3c62828e3 settings framework and section metadata keyed (487 -> 425)
SettingsSection now carries titleKey/descriptionKey instead of title/description, and
the sidebar and content pane resolve them. The section arrays are module-level consts
holding JSX content, so they cannot call a hook — the data holds keys and the renderers
translate. Sidebar search matches the RESOLVED words, so it works in the language on
screen.

Capital K in titleKey is load-bearing: docs-check-settings.ts greps key:\s*'…' for
route segments, and a lowercase title_key would have fed it twenty bogus paths.
Verified 0 bogus, 26 declared, 20 offered, 0 missing.

Section titles now share one key with the Spotlight rows that name the same section,
per 'one key per meaning'. Two casings had to converge, so Settings shows Spotlight's:
'Change Password' now reads 'Change password'.

Settings sidebar search placeholder was 'Search...'; it is now 'Search settings…',
which is what the skeleton catalog already said and what distinguishes it from the
global Spotlight.
2026-08-23 04:46:07 +00:00
pastilhas 926034750f keyed the dock, header and shell chrome (555 -> 487)
Layout reaches zero. The dock, header, user menu, theme switcher, spotlight,
shortcuts modal, screen-error fallback, rescan and bug report.

Three things worth knowing:

CORE_DOCK_ITEMS now carries labelKey instead of label, and one useDockItems() hook
resolves the composed list. DashboardLayout, Spotlight and DockSettings each spread
the same two sources before, so each would have had to resolve labels itself.
Plugin tiles keep the name from their own manifest — a plugin's strings are not ours.

Spotlight resolves labels INSIDE the hook, so rank() still matches what the reader
can see rather than a dot-path. Its to: '/settings/...' literals are untouched, which
is what docs:check parses — verified still 20 offered, 0 missing.

The rescan toast said 'N skill(s)'. That is English giving up on a plural, so it is
now a real plural entry per noun and reads correctly at 0, 1 and 2 in both locales.

ThemeSwitcher used t as its theme loop variable; renamed to entry.
2026-08-23 04:40:39 +00:00
pastilhasandClaude Opus 5 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>
2026-08-23 04:40:27 +00:00
pastilhas 2765e241d3 TranslationKeys stops at a plural entry instead of walking into it
A phase 0 defect that could only surface once a real catalog used a plural. The type
walked into { one, other } and contributed 'foo.one'/'foo.other' to the key union, so
t('foo', { count }) — the whole point of D7 — did not compile, while t('foo.one') did.

Found by the type checker on the first genuine plural in phase 2 (the rescan toast).
Mirrors isPluralForms at runtime: every key a CLDR category, every value a string.
Pinned with type-level @ts-expect-error assertions, since nothing else would notice.
2026-08-23 04:38:26 +00:00
pastilhas 36e18f935d scanner: do not count commented-out code
Header.tsx has a disabled dev-console button inside a JSX comment and
IntegrationsSettings has a commented-out section literal. Four hits in
officer-web, all of them code nobody ships — and an area cannot reach zero while
a comment counts against it.

Only two shapes stripped, both unambiguous: block comments, and lines whose first
non-space characters are //. Stripping every // would eat the tail of any string
holding a URL. Verified the delta is exactly those four.
2026-08-23 04:32:06 +00:00
pastilhas 0ddcdcbdbf memoize t in the typed wrappers, and give app code typed access to ui.*
Each wrapper built a fresh closure every render, so `t` in a dependency array
recomputed forever. That is the documented useClient()/Jellyfin trap in
platform/CLAUDE.md, and phase 2 needs t in useMemo deps immediately.

useTranslation in officer-web now also returns tUi, typed against the shared
components' catalog. 'Cancel' and 'Close' recur across the shell and already exist
in ui.* — the alternative was importing two hooks in a hundred files.
2026-08-23 04:30:07 +00:00
pastilhas 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.
2026-08-23 04:27:04 +00:00
pastilhasandClaude Opus 5 845699e697 deploy: arming for the first time has nothing to rehearse against
A commander read the general doctrine — run the action once before arming — and
proposed replaying the last release against it. Fair reading of what was written,
and wrong here: every tag already has a success record, so the claim refuses, and
the only way to manufacture a trigger is to tag a release, which it must never do.

So the deploy procedure now says it outright: arm, and state that the action has
never executed under you. The next real release is the first run.

This belongs in deploy documentation rather than commander doctrine. The general
rule is sound; what is specific to this job is that its triggers are all spent, and
the agent should not have to be told that by the owner after the fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:23:43 +00:00
pastilhas fe000541b7 hold shared components at zero hardcoded strings
The area reached zero convertible strings, so it goes in ZEROED and the scanner's
--check now fails if a future feature reintroduces one. Verified both ways: exit 1
naming the string and file when one is added, exit 0 when it is gone.

Enabled per area rather than globally, as the doc says — officer-web and
officerdev are still at 559 and 578, and would be red for weeks.
2026-08-23 04:19:41 +00:00
pastilhasandClaude Opus 5 dfbf9be61c the two messages are an obligation in the brief, not advice in the skill
A commander read the skill section on telling the owner and did not mention it in
its read-back, so the owner would have had to remind it — which is the failure, not
the remedy. Doctrine in the skill is advice a commander may or may not weigh;
the brief is the prompt and always arrives. Same lesson as moving the output
constraints there earlier tonight.

So the brief now states it as owed: two messages per event, one when the thing
happens and one when the work is done, and never having to be asked. The skill
still carries the how.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:18:32 +00:00
pastilhas be29ae7aa7 delete two unreferenced components from another codebase
MarkdownEditor and ui/start-confetti-button had no importers anywhere in the repo
and both carried a different product's content: MarkdownEditor hardcoded
https://app.pertento.ai/logo.png with alt="Pertento", and start-confetti-button
spoke about 'the experiment' and 'Draft/Created' statuses.

Translating either would have put feature words into ui.*, which D6 forbids —
a shared namespace may not name a feature.

todo/inputs-outside-forms named MarkdownEditor as deliberately-unwrapped, so that
entry is updated rather than left pointing at a deleted file.
2026-08-23 04:18:08 +00:00
pastilhas e3e790fd2b command block and colour picker keyed
ColorPicker's one string was placeholder="Font Weight" on an <input type="color">,
where a placeholder is both wrong text and inert — browsers do not render one on a
colour input. Keyed it as ui.colorPicker.placeholder and moved it to the hex text
field beside it, which is a real text input and had none. Translating it in place
would have created a key guaranteed never to be seen.

Both files still have no importers; CommandBlock stays because commands the user
might run need a copy button by convention.
2026-08-23 04:16:54 +00:00
pastilhas 2b0b671895 scanner: separate never-translate hits from convertible ones, and stop counting tailwind as prose
The --check guard compared an area's whole hit count against zero, including
'thrown errors' — which the doc's never-translate list forbids converting. An
area holding one legitimate new Error() could therefore never be added to
ZEROED, so the debt guard could never be switched on for it at all. Categories
now carry neverTranslate, are still reported, and are excluded from --check.

Also drops sonner's 'group-[.toast]:text-muted-foreground', matched as an object
label. The filter tests per token rather than per string: the first attempt
tested the whole string and ate a real label, 'Application type:'.

--check now prints the offending strings and files instead of only a count.
2026-08-23 04:13:21 +00:00
pastilhas 2ec3f11cdd shadcn primitives and logos keyed: dialog, sheet, pagination, carousel, breadcrumb, theme toggles
These are the strings a user meets everywhere, mostly sr-only: the dialog and
sheet close buttons, pagination labels, carousel arrows, the breadcrumb ellipsis
and both colour-mode toggles. nav aria-label='pagination' became a real key too —
a screen reader announces it, so it is user-facing.

Logo alt text keeps the product name and translates only around it
('Logótipo Officer'). Arrow-button insets became logical (-start-12/-end-12).

The three 'useX must be used within <Y>' Errors are deliberately untouched:
never-translate, per the doc's list.
2026-08-23 04:10:39 +00:00
pastilhas 5ce1a2928e inputs and data views keyed: search, select, tabs, state blocks, data table
The DataTable's pagination carries the only interpolated strings in this area
('Showing page {{current}} of {{total}}', '{{size}} / page'). No plural forms:
nothing here is a count-varying noun, so inventing one would only add a key
nobody uses.

DataTable.test.tsx now renders inside an I18nProvider with the real en-GB
catalog, so it still asserts the English a user sees rather than a dot-path.

Left/right utilities became logical (ms-/me-/ps-/pe-/start-) per D3.
2026-08-23 04:10:33 +00:00
pastilhas 339d31f05c ui.* catalog and the dialogs: error dialogs, confirm and paper dialogs keyed
Establishes the ui.* namespace for phases 2 and 3. Both catalogs complete, so the
key-parity test holds. Two English copy defects fixed while keying, because the
Portuguese must not inherit them: 'Your are not authorized' -> 'You are', and
'Please Signin again' -> 'Please sign in again'.

The server's own message in ServerError/CustomError stays verbatim — it is a
response, not UI copy (D1).
2026-08-23 04:10:27 +00:00
pastilhas a444280cb4 deploy report 2026.08.23.03 2026-08-23 04:02:58 +00:00
pastilhasandClaude Opus 5 c0788d15c0 release 2026.08.23.03
Roles become a table and localization phase 0 lands. Both groundwork; nothing in
the UI changes and nothing reads the new table yet.

Schema change, additive only — the roles table is new and no constraint is added
to an existing column, so it cannot fail on existing data. postdb:push seeds it.
No dependency change; the two lockfile lines are the local i18n workspace being
declared as a dependency of officerdev.

The plan names the check that matters: four rows in roles, per host, by query —
db:push can fail and exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:56:07 +00:00
pastilhasandClaude Opus 5 7704867e05 roles are a table, seeded, with nothing depending on it yet
Phase 0 of moving roles out of a TypeScript union. The four the code names become
rows, marked isSystem because each is referenced by name somewhere:
rolePermitsDevTools tests for 'Developer', bootstrap writes 'Super Admin',
users.role defaults to 'Member', and 'Admin' is what rolePermitsDevTools
deliberately excludes. Renaming or deleting one of those is a code change, so the
flag is there for a future delete path to refuse on rather than for the failure to
surface later as an account that cannot log in.

DELIBERATELY NO FOREIGN KEY on users.role. That change cannot go through db:push:
push creates the table empty and adds the constraint in the same run, so every
existing account violates it. Verified against a scratch database — push reported
the violation, created the table, skipped the constraint, and exited 0. The FK is
a separate release, once every host holds these rows.

So this phase is additive and cannot fail, which is what makes it safe to hand to
an unattended rollout across five hosts. Nothing reads the table yet; USER_ROLES
is still the type and authorize.ts is untouched.

The seed runs from postdb:push, so a host gets the rows on its next schema push
with no extra step in a deploy plan. It never overwrites: a description edited on
one server must not be reverted by the next deploy.

Verified here the way the deploy doc now requires — by query, not by exit code.
Four rows present, seed idempotent on a second run, and a second push is a no-op,
which is the proof that the schema and the database agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:52:38 +00:00
pastilhasandClaude Opus 5 04a4ab33dd deploy: db:push can fail and exit 0, so check the database instead
Verified against a scratch database on 2026-08-23. A push whose foreign key
violated existing rows printed the Postgres error, created the new table, left the
constraint uncreated, and returned zero. `bun db:push` wraps it, so a plan step
that reads `bun db:push` can report success over a half-applied schema.

The rollout already survived this twice only because the agent decided on its own
to verify per host by query rather than trusting the exit code. That instinct is
now the instruction.

Also documents the shape that cannot go through push at all — a new table plus a
foreign key onto it from an existing column, where the seed has to happen between
the two halves — and what direct SQL is allowed to look like when a plan calls for
it: the Drizzle schema stays the source of truth, the SQL comes from the plan
rather than from the agent, it is idempotent, push is run afterwards and expected
to be a no-op as proof the two agree, and every host is verified by query.

And one refusal: never `--force`. Its job is answering the destructive prompts
unattended, which on five hosts holding real data is not a convenience.

This is deploy documentation, not commander doctrine — a developer running
something similar should be able to reach the same conclusions, and the commander
architecture does not need to carry it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:49:48 +00:00
pastilhas b640ec486d i18n: default-language picker offers only shipped locales; widen stored bare codes on read 2026-08-23 03:43:35 +00:00
pastilhas 0bd8c6c16a i18n: officer-web catalog goes regional (en-GB, pt-PT), bridge deep-merges app/panel/ui and sets dir 2026-08-23 03:43:01 +00:00
pastilhas 6ac9187c52 i18n: scaffold panel.* and ui.* locale catalogs, wire panelCatalogs into officerdev's barrel 2026-08-23 03:42:53 +00:00
pastilhas dcd11a3b17 i18n: plural selection via Intl.PluralRules, dev-mode miss warning, useFormat, key-parity helper 2026-08-23 03:42:46 +00:00
pastilhasandClaude Opus 5 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>
2026-08-23 03:21:21 +00:00
pastilhas 64894ad96e deploy report 2026.08.23.02 2026-08-23 02:59:30 +00:00
pastilhasandClaude Opus 5 0a8fe1c2c8 release 2026.08.23.02
SendOff, and the deploy procedure's own corrections from the last rollout.

Schema change — sendoff_devices and sendoff_senders are new, so bun db:push is a
step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:52:30 +00:00
pastilhasandClaude Opus 5 2137d06498 the folder name is served, not derived three times
Mobile asked for `/targets` to carry each person's display name as the folder will
be named, so a picker can honestly say where files will appear. Right request: the
writer already computes that exact string to create a directory, and any client
computing its own would eventually disagree with it — which means telling somebody
the wrong place to look.

So `displayNameFor` is the one definition and the writer calls it too. `/targets`
gains `displayName` per person, and `myFolder` for the whole response: files land
in the RECIPIENT's tree under the SENDER's name, so that is one value rather than
a field on each row. The two are easy to confuse and the distinction matters — a
picker showing `displayName` as the destination folder would name the wrong one.

The web panel now uses both instead of its own copy of the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:50:23 +00:00
pastilhasandClaude Opus 5 ca7d3c657b a send form for SendOff, and no receiving one
/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:47:47 +00:00
pastilhasandClaude Fable 5 b22e9d86c0 COMMS: mobile adopts all three sendoff deviations, one request for /targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 03:45:04 +01:00
pastilhasandClaude Opus 5 2095b6a717 COMMS: SendOff backend is in, and it differs from the draft in two places
Opens the channel for mobile. The endpoints as built rather than as specced, both
deviations with the reasoning, and the parts that need no app build at all.

The two that matter: device registration moved to /api/sendoff/devices, because
/api/devices collided with the push-token registry and those are different
populations; and toUser is an account id rather than a username, because
users.name is nullable and a username is not a reliable address.

Both are cheap to change here and expensive to guess at, which the note says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:41:28 +00:00
pastilhasandClaude Opus 5 4ea25d76c8 todo: the SendOff size limit should be per user, from settings
512 MB was chosen as enough-for-now on the day the feature landed, and it applies
to every account equally. The owner asked for it per user before the code was an
hour old.

Notes the adjacent question while it is in view: a size limit bounds one send, and
nothing bounds what a sender accumulates in somebody's tree over a month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:34:15 +00:00
pastilhasandClaude Opus 5 b94a5e90b8 SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push.
No staging area, no transfer table, no ack, nothing to expire. Every account
already has a home, a file browser over it and a Files app on the phone, so a
delivered file is an ordinary file from the moment it arrives.

`Documents/SendOff/<machine>/` for something you sent yourself, and
`Documents/SendOff/<sender>/` for another account's files. The home is the one
that account actually browses — the owner's real login home, a member's managed
home — because the two are different directories and writing to the wrong one
makes a delivery invisible rather than wrong.

CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant
answers "may this role send files", and the person who should decide whether
somebody may write into your home is you. Absence denies, and a sender who was
not allowed does not see the recipient in their picker at all — the endpoint
answers 404 for "no such account" and for "did not allow you" alike, so it cannot
be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving
still needs their say-so.

The sender's folder name comes from the token and never from the payload, so it
cannot lie about who put a file there. Names are sanitised to one path segment and
filenames to their basename — verified: `../../../../etc` becomes a literal
folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside
the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the
sender cannot see the directory and would have no way to know.

The push carries the folder name and a file count, and the catalogue renders
"3 file(s) from andre" on the device. That relaxes "content does not travel" by a
folder name and an integer — the owner's call, on the grounds that the file never
leaves the tailnet and a notification nobody can act on is not worth sending. A
filename would be content; whose folder it is, is addressing.

`kind: 'app'` rather than `confined` even though it writes into a home: the path
is one fixed shape chosen by the server from the caller's token, so there is
nothing to traverse and nothing to read back. Requiring a Linux user would mean an
account without one could not be sent a file, which is most of the point.

Backend and verified end to end. The web send form is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:32:09 +00:00
pastilhasandClaude Opus 5 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>
2026-08-23 02:12:45 +00:00
pastilhas b5d9f591ee deploy report 2026.08.23.01 — four hosts, two unreachable
edge-pertento-bridge never completes an SSH banner exchange; production-pertento-bridge
presented a changed host key and was left alone. Neither was pulled or restarted.
2026-08-23 02:07:10 +00:00
pastilhasandClaude Fable 5 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>
2026-08-23 01:57:22 +00:00
pastilhasandClaude Opus 5 dee0b89bd7 release 2026.08.23.01
The commander creation flow rebuilt end to end, the delete path that actually
removes one, /minesweeper, and docs:check.

Schema change — command_hq_spawned is new and command_hq_sessions gains name, so
bun db:push is a step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:56:03 +00:00
pastilhasandClaude Opus 5 aa35428dde usage limits are read live, and a cached reading belongs to one account
`limitsCache` was keyed on nothing. Switching account changes which credential
`readOauthLogin` returns, and the cached entry was served anyway — so the panel
showed the previous account's percentages under the new account's name for a
minute, and on a failed probe for as long as the probe kept failing. Reported
maxed out on one account, switched, and the numbers did not move.

So the limits have no TTL now: every request probes. They are per-account and they
are the number somebody opens the panel to read, so a stale one is worse than a
slow one.

The store stays, for the failure path only, and keyed on the account it was
measured for. A flaky request degrades to that account's own last reading rather
than to a blank panel or — much worse — to somebody else's numbers. A reading that
cannot be attributed gets no fallback at all rather than a shared one.

The client's five-minute stale window went with it: that query is only the instant
first paint, and holding it meant painting the old account's stored row until the
refresh landed.

The transcript scan keeps its cache. It reads ~90MB and its answer is the same
whichever account is signed in — it counts what this machine wrote, not what an
account is allowed — so a switch does not invalidate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:54:05 +00:00
pastilhasandClaude Opus 5 854f957058 a recorded pid means armed, whatever route started the loop
`watcher_armed` is set on one path only — the background-job dialog's
hand-back-and-re-arm. A commander that starts its loop as an ordinary tracked
background job records its pid correctly and still reports unarmed, so Command HQ
shows nothing to stop. That is the one thing the column exists to make possible.

The pid file is the fact and the flag is a claim, so the fact wins: armed if the
row says so OR a recorded pid is alive. Verified against a live commander that
had armed itself and read as unarmed.

Deleting it already worked — that path reads the record rather than the flag —
so this closes the gap between what the platform could do and what it admitted to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:44:06 +00:00
pastilhasandClaude Opus 5 edeb434233 the opening depends on what it found, and it never starts its own work
"Reply with two sentences and one question" was fitted to one directory — the one
that already had the whole procedure written down. Summarising is right there and
useless in an empty folder, where the commander has nothing to summarise and has
to ask what the job even is. So the brief asks it to decide which case it is in
and gives it both openings, and drops the sentence count for "whatever the answer
honestly needs and no more".

It also states the boundary that both comparison runs crossed: you watch for the
thing and respond to it, you never cause it. Both offered to tag fifty commits
and roll them out — and the tag is the trigger, so that is a watcher starting its
own work and then watching for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:22:44 +00:00
pastilhasandClaude Fable 5 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>
2026-08-23 02:02:38 +01:00
pastilhasandClaude Opus 5 730aeab6c5 the finished message wins over the buffer that was streaming it
A reply rendered as "ipped." — first two characters gone — while the durable log
held it complete. Nothing was lost; the client discarded it.

The streaming buffer is an accumulation of deltas and is only as good as the
socket. The `assistant:text` event that ends the stream carries the whole message
and arrives right after. Committing the buffer and ignoring that text means a
single dropped frame is permanent, even though a correct copy came in immediately
behind it.

Losing a frame is not exotic: restarting the server mid-turn does it, which is how
this was found — three times tonight, by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:54:32 +00:00
pastilhasandClaude Opus 5 2651c45733 put the rules where they are guaranteed to arrive
Three intakes in a row opened with a five-bullet audit, each one different, and
every rule written to stop that lived in the Commanders skill. The skill was
never read: zero Skill calls, zero mentions, in the whole session. What tells a
commander to read it is the preamble, and the preamble cannot exist on the first
turn — no row and no transcript id yet — which is the one turn this is about.

So turn one has been the model's default behaviour throughout. That explains the
length and it explains the variance, which is the more useful half: same prompt,
same directory, wildly different openings, because nothing in the prompt said
what the opening should be.

The brief is the prompt, so it always arrives. It now carries the constraint —
two sentences and one question, no headings or bullets or paths or hashes, no
"Oriented", no claiming to have verified — and tells it to read the skill for
everything else. The constraint is on what it says, not on what it does; reading
as long as it likes was never the problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:47:50 +00:00
pastilhasandClaude Opus 5 91bc4ac74f the platform keeps the commander's name
The name only ever lived in the transcript's summary record, which the harness
cannot write until the first turn ends. So the list showed the opening brief
until then, while the header beside it showed the name from local state, and
every fix for that was a cache-invalidation trick timed against somebody else's
write. Three attempts, all of them fixing the symptom.

`command_hq_sessions.name` is written when the commander is filed, before the
first turn produces anything, and the list and detail routes prefer it. Nullable,
so rows filed before the column fall back to the transcript. The rename still
happens — it is what titles the conversation everywhere OUTSIDE Command HQ — it
just is not the source of truth any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:46:17 +00:00
pastilhasandClaude Opus 5 a51a969cd0 a file with no turns in it is not a conversation
Four "(untitled) · 0 msgs" rows accumulated in the sessions list from one evening
of commander testing. They are transcripts containing only harness metadata —
last-prompt, ai-title, atis-latch — written after a session was deleted or before
it ever sent anything. Filtered at the list, not deleted on disk: the files belong
to the harness, and a cleanup racing it would be the same class of bug that
produced them.

Which is also the cause of two of the four. The delete path resolves officer's
session key from two in-memory maps, and both answer only for a session younger
than the process. After a restart neither does, so no kill is sent, nothing is
awaited, and the transcript is removed while the harness is still writing — which
writes it back. Same failure as the events cleanup, same durable answer: the event
rows carry both ids, so ask them when the maps cannot say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:28:56 +00:00
pastilhasandClaude Opus 5 5c3ffa7a06 the watcher loop lives next to the claim it depends on
prompts/deploy-commander.md treats the poll loop as something an agent rewrites
from scratch on every arming. That is also how the same defect keeps arriving:
the loop left at data/deploy-watch.sh gated on "$v.done" — a file written only
on SUCCESS — and never called deploy-claim.sh at all. A failing release
therefore spawned a fresh paid agent every poll. Measured against that script
with a stub agent: 10 spawns in 20 seconds at a 2s poll, which is 30 an hour at
its real one, all night.

That is exactly the storm deploy-claim.sh exists to make impossible,
reintroduced in the one place the claim cannot defend — the caller that decides
whether to call it. So the loop moves into the repository beside it.

The baseline is the claim, not the success: a release that failed is handled,
and handled means a human looks at it. Arming today fires on nothing, because
all six existing releases carry a .done and deploy-claim.sh honours one.

The claim is taken before the spawn, which means the child has to be told it
holds one. It is about to read the procedure, whose claim instruction is
addressed to whoever reads it — an obedient child claims for itself, gets exit
1, and correctly refuses the deploy it was started to perform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:20:56 +00:00
pastilhasandClaude Opus 5 1155449e4b a transcript with nothing in it can say nothing
"Send a message to start" is right on the chat screen and wrong under a setup
card that already says what to do — it contradicts the Start button above it and
takes 200px of height doing so, which is what squeezed the new-commander pane
into a scroller a third of the panel tall.

`emptyState` is optional and three-valued on purpose: undefined keeps the
default, a node replaces it, null shows nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:16:28 +00:00
pastilhasandClaude Opus 5 a2ddf258b6 "started" is a latch, not a message count
The pre-start pane keyed off `messages.length === 0`, which stands in for "not
started yet" right up to the moment it matters. Lose the socket mid-turn and the
messages come back empty, so the setup card and its Start button reappear on a
commander that is already running — and pressing Start sends the opening brief a
second time.

That happened tonight, after a restart I ran while a commander was live: the turn
died, the pane reset, Send re-sent the brief, and the commander was asked to
introduce itself twice. The owner deleted it and started over, which was the right
call and should not have been necessary.

So it latches. Once anything says this conversation exists — a message, a
transcript id, a turn in flight — it stays started. Deliberately not derived from
live state, since surviving the moment that state goes empty is the entire job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:11:04 +00:00
pastilhasandClaude Opus 5 5c802b07b5 a command dropped for want of a sidecar has to say so
`sendFire` looked up a sidecar, and if none was registered it did nothing.
No log, no error, no return value — the command simply stopped existing. That
window is real and entirely predictable: every `pm2 restart officer` has one
while the sidecars reconnect, and somebody clicking a button has no idea they
are in it.

The cost landed hardest on answering a question. `answerClaudeQuestion` in the
agent documents this exact failure in a careful comment and cannot report it,
because the command never arrives to be reported. So the question card kept
spinning, which from the person's side is indistinguishable from a slow agent,
and the only move left is to send another message — the worst possible thing to
do to a waiting tool call. Observed tonight, on a restart I ran myself while a
commander was mid-question.

So `sendFire` returns whether anyone took the command and warns when nobody did,
and the two paths where a person is actively waiting — a question answer and a
plan decision — tell them instead of leaving the card to spin.

The other five callers are unchanged and now leave a log line, which is the part
that was missing when this was diagnosed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:06:30 +00:00
pastilhasandClaude Opus 5 383df51a99 the new-commander pane scrolls, because the panel it lives in zooms
Setup card, start button and guide were three `shrink-0` siblings, so they took
whatever height they wanted and nothing could scroll past them. The guide carried
its own `overflow-y-auto`, which never once engaged — `shrink-0` had already given
it its full natural height, so there was nothing to overflow.

Fine at 100%, and the panel system is zoomable. At 120% the setup ran off the
bottom of the panel with no way to reach it, which is the zoom somebody uses
precisely because they want to read it.

So the scroller moves up to where the height is actually constrained, and the
composer keeps `shrink-0` before the first message so it stays pinned at the
bottom whatever the zoom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:02:25 +00:00
pastilhasandClaude Opus 5 3551a3af71 give the commander the name it was given
Named "Platform Deploys" and started in the platform checkout, the second intake
asked whether we were working on the urgent routing item or on commanders. The
name is the one thing the owner says before anything else — the whole reason they
type it into an empty field — and it went nowhere near the model. It was used to
title the transcript and for nothing else.

So the opening brief now carries it, and says what it is: not decoration, the
subject, chosen before anything else was said. Reading a directory with a subject
in hand is a different exercise from reading it cold, which is what the first two
attempts did.

Also invalidates the per-commander query alongside the grid when a turn ends. The
first pass at that fixed the list pill and left the header beside it showing the
opening brief, so one commander displayed two different wrong names depending on
which panel you looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:53:58 +00:00
pastilhasandClaude Opus 5 038251f9c9 refresh the commander grid when its first turn ends
The name is written server-side into the transcript's summary record on the first
`result` — it cannot land earlier, because the transcript does not exist until
then. Nothing told the client, so the list kept the title it fetched beforehand.

For a self-started commander that title is the opening brief, so the pill read
"I have given you a name and a working directory…" next to a header showing the
real name from local state. Two names for one commander, both correct about
different moments.

The falling edge of `isGenerating` rather than a poll or a shorter staleTime:
it is the one moment the server is known to have written something the list is
displaying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:44:09 +00:00
pastilhasandClaude Opus 5 4f590f8879 pin both id spaces with a test that fails against the old code
Verified by reverting the fix and watching it go red on exactly the case the live
delete hit — the post-restart one, where no caller can supply a session key. A
regression test never seen to fail proves nothing.

It touches the real database, which the rest of the suite avoids on purpose, and
that is the point: this defect was never in a decision, it was in which column a
`delete` matched. A test built on a fake store would have passed against the
broken version and the fixed one alike.

Contained by construction — every id is a synthetic uuid in a block no harness
produces, nothing reads live rows, nothing deletes by anything but those ids, and
each test removes what it wrote.

The third case is the one that will earn its keep later: it asserts a delete
leaves a NEIGHBOURING session's events alone. Resolving a key from row payloads is
exactly the kind of query that starts matching too much when somebody widens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:19:07 +00:00
pastilhasandClaude Opus 5 c04fa15eb5 ask the events table which key a transcript belongs to, instead of a live map
The id-space fix worked only for conversations younger than the process. The
caller resolves a session key from an in-memory map filled as sessions announce
themselves, so anything created before the last restart resolves to null and the
key list arrives empty — which is exactly what the first live test did. Everything
else cascaded; the events stayed, silently, for the one reason the code could not
report.

The answer was already in the rows. Every event carries `claudeSessionId` in its
payload: durable, restart-proof, and the one place the two id spaces are written
down beside each other. One event carrying that id resolves the key for all of
that session's rows — including the majority that carry no id of their own.

`sessionKeys` stays as a fast path for a live session and is now a hint rather
than the answer. A cleanup that only works for conversations younger than the
process is not a cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:16:42 +00:00
pastilhasandClaude Opus 5 2aef23c8ba a commander starts its own conversation, instead of being handed one
Starting a commander meant writing a good opening prompt, which is the hard part
of the whole thing — so in practice somebody else wrote it and pasted it in. That
is not a flow, it is a person acting as the intake form.

Now the two decisions that cannot be guessed stay with the owner (a name and a
working directory) and the rest is a button. It sends a brief the MODEL reads —
"I have not told you what I want yet, orient yourself and ask me one question" —
while the transcript shows "Set me up.", which is what a person would have typed.
That split is `effectivePrompt` vs `displayText` and the chat path already had it.

What to ask, in what order, and to read the directory BEFORE asking anything, all
live in the Commanders skill under "When you started yourself". Deliberately not
here: capabilities are re-read from disk every run, so rewording a question costs
nothing, while the same words in commander-block.ts cost a restart each time. The
block carries a pointer and no doctrine.

Orienting first is what makes this work on a real request. Told "automate the
platform deploys", a commander that has read the directory finds `deploy/`, the
previous releases and the deploy skill, and asks whether that is still how you
want it — instead of inventing a scheme next to the one already there.

`sendPrompt` gained an options object rather than a tenth positional parameter.
Nine is already past where a call site reads as a row of undefineds.

The composer stays enabled beside the button: typing your own opener still works,
it just is not the only way in any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:04:25 +00:00
pastilhasandClaude Opus 5 eefcb06e35 stop the harness before deleting its transcript, and wait for it to actually stop
Deleting a commander removed its transcript and the `claude` CLI wrote it
straight back. Verified rather than inferred: the file returned with 22 lines,
every one timestamped after the delete, and the deleted commander was sitting in
/chat as "(untitled)" — no longer in `command_hq_sessions`, so nothing filtered
it out. Delete a commander, get an untitled chat back.

Two causes, and the first one hid the second.

The kill never happened. `killClaudeSession` was imported straight from
`claude-manager` into a route that runs in `officer`, while the session map lives
in `officer-claude-code`. It read an empty map in the wrong process and returned
false every time. `claude:kill` and `killClaude` already existed for this; the
route just wasn't using them.

And killing is not being dead. `abort()` returns while the CLI is still
unwinding, and officer does not own that file — the CLI does. So a session now
carries an `exited` promise resolved by the same exit hook that already reaps it,
`claude:kill` replies only once that resolves, and both delete routes await the
reply before touching the filesystem.

Bounded at 5s and never fatal. A child that will not die delays a delete by
seconds instead of hanging it, and a sidecar that is down leaves the caller with
exactly the race it already had.

`markExited` fires before `dropDeadSession`'s identity guard, deliberately: a
waiter has to be released even when a newer session has taken its key, or it
blocks for the full timeout on a process that died on schedule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:55:29 +00:00
pastilhasandClaude Opus 5 8ac3ca7fe5 forgetting a conversation needs both of its ids, not one twice
`chat_session_events` is keyed on officer's SESSION KEY. The other three tables
are keyed on the harness's TRANSCRIPT ID. Both are uuids for the same
conversation, and `forgetChatSessions` took one list and passed it to all four —
so the events delete matched nothing. Not sometimes: by construction, every time.
A deleted conversation left its entire durable output behind.

It survived two verifications because counting events BY TRANSCRIPT ID also
returns zero by construction, and zero read as "cleaned". What settled it was
reading an orphaned event instead of counting it — the payload carries both ids
side by side and says outright which key the row is filed under.

So the parameter is now named for its id space. That is the whole defence and it
is the only one available: nothing else distinguishes the two, since both are
uuids of the same shape, and a positional `string[]` cannot be got wrong loudly.

Empty `sessionKeys` is legitimate and common — a session spawned by `claude -p`
outside officer's websocket never had a key and never wrote an event, which is
exactly a commander's children.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:50:29 +00:00
pastilhasandClaude Fable 5 03e7ca39c3 minesweeper: narrow send(), and tell the ws mirror about the provider
two failures another session found in a shared checkout, both mine.

send() took a plain Uint8Array, whose default buffer type is
ArrayBufferLike and may be a SharedArrayBuffer — WebSocket.send takes
only BufferSource. narrowed to Uint8Array<ArrayBuffer>, which every
caller already builds and which the terminal relay already does.

registry.test.ts keeps REAL_WS as a hand-written mirror of server.tsx's
handlers map, and its own comment says that drift is what it catches. it
caught mine: the route and the registry entry landed, the mirror did not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:22:23 +00:00
pastilhasandClaude Fable 5 3254925506 remove mint-tmp.ts, committed by accident
a throwaway that mints an owner jwt for end-to-end testing. its cleanup
never ran because the command that created it timed out, and the next
`git add -A` swept it in. no secret in the file — it reads the signing
key at runtime — but a token minter does not belong in the tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:16:01 +00:00
pastilhasandClaude Fable 5 a3fd3ade01 minesweeper: hold the client in a ref, not in a dependency list
confirmed playable after this.

useClient() rebuilds its verbs on every render, so `refresh` — which
listed `client` — got a new identity every render, its mount effect
re-ran every render, and each run set state and caused another render.
an endless fetch loop that starved the socket, so onReady never ran and
the panel sat at 'connecting…' forever.

the backend was never at fault: the relay completed a full rfb handshake
through wss://platform.officer.dev with a real token before this was
found. ruling the server out is what left only the client.

same trap and same fix as the jellyfin playback reports, which CLAUDE.md
already warned about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:15:44 +00:00
pastilhasandClaude Opus 5 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>
2026-08-22 22:11:12 +00:00
pastilhasandClaude Opus 5 749c80c877 the server says which live sessions are commanders, and can return just one kind
/chat/live already classified every row from `command_hq_sessions`; what it did
not do was filter, so each client repeated that itself. Fine for the web list,
which shows both tabs at once. Wrong for the iPhone app, which swipes between
live chats, has no room for tabs, and must simply never be handed a commander.

`?kind=agent|commander` does it server-side. Counts always describe the whole
set regardless of the filter — the web tabs show both numbers side by side and
both must be right whichever tab is open, so a filter that also hid the other
count would force a second request to render one screen. An unrecognised kind is
a 400 rather than "everything": a mistyped `?kind=agents` silently returning
commanders is the precise swipe-order bug this exists to prevent, so the plural
forms are accepted too, since the client's own type is already plural.

And /chat/sessions no longer tags rows it is about to delete. It called
listCommandHqSessionIds twice — once to mark commanders, once to remove exactly
those rows four lines later — so isCommander was false on every row that
survived. A field that could only ever lie, plus a second identical query per
request to produce it. A list that excludes commanders has nothing to say about
which of its rows is one; withCommanderFlag had no other caller and is gone.

Not restarted: the shared checkout currently fails typecheck on another agent's
untracked Minesweeper work, and a restart is when half-applied work ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:01:28 +00:00
pastilhasandClaude Fable 5 af918251d3 /minesweeper — a wine session streamed to the browser over RFB
a websocket byte relay onto x11vnc's tcp socket, the same shape as the
terminal's relay onto the pty sidecar. this is the piece plugins/desktop
could not have: a plugin cannot claim a websocket provider.

the rfb client is ours rather than vendored novnc — raw and copyrect,
one pixel format we dictate so a rectangle memcpys into ImageData. we
control the server, so the other eight encodings are surface we would
carry and never run.

execution, so owner-only: wine runs as the owner, which makes the game's
menus file dialogs over the owner's home.

two bugs worth keeping. the token key is BEARER_TOKEN, not token, so the
upgrade 401'd before any rfb byte moved. and the /status health check
opened a tcp connection to prove liveness — libvncserver blocks its
accept loop peeking for a websocket handshake, so a polled health check
filled the backlog and locked out the real viewer. a liveness check must
not be indistinguishable from a client; it reads /proc/net/tcp now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:39:35 +00:00
pastilhasandClaude Opus 5 ae27478a27 the platform keeps its own transcript-id mapping, because the sidecar's is invisible to it
The commander block still was not arriving. Not the delivery mechanism this time — the LOOKUP. Building the
block needs the transcript id, the platform had only officer's session key, and it asked the sidecar's
`state.ts` for the mapping. That map lives in the sidecar's memory; the platform process has its own empty
copy of the module, and re-reading the state file from here does not find a live session either. Verified
both ways before changing anything.

So the platform keeps its own map, filled from the `session:claude` message it already handles — the one
moment the mapping is announced. It is not derivable from anything: it is stated once, mid-turn, and
whoever needs it either records it as it passes or does without.

The delete route had the identical problem for the same reason and now asks the platform's map first, keeping
the sidecar lookup as a fallback for a session that predates this process.

Third distinct bug today from these two ids, and they are all the same shape: a lookup keyed on one id space
against data held in the other, failing silently because both are UUIDs and neither side can tell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:32:25 +00:00
pastilhasandClaude Opus 5 0bfad0e592 deleting a commander ends its session, not just its loop
Its own test caught this too. Delete removed the row and the transcript, and the transcript was BACK a minute
later — because the commander's `claude` process was still alive and rewrote it. That happened twice tonight
and both times it read as the delete having failed.

Killing the loop was never enough: the loop is a grandchild of the session, so ending it leaves the session
running, still holding the conversation open and still able to write.

So the session is ended first, with `killClaudeSession` — which already existed and does the whole teardown
(abort, close input, settle pending plans and questions). It takes officer's session key while this route
holds a transcript id, so the map is read backwards through `findSessionKeyByClaudeSession`, which exists for
the same reason a refreshed browser holds only the uuid.

Ending the session takes its children with it; the pid-based stop still runs afterwards, catching anything
that outlived its parent. The response reports `sessionKilled` beside `processesStopped` so the two are
distinguishable in a report rather than one number standing for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:25:39 +00:00
pastilhasandClaude Opus 5 aad382f4be a commander is told where to record itself on a TURN, because the preamble never reaches it
Its own first test caught this: the commander armed a perfectly good watcher, established a baseline
correctly, and wrote no loop-pid — so deleting it would have left the loop running.

The block was composed into the system preamble, which `createSession` builds ONCE, on a conversation's
first turn. For a commander that is exactly the turn where the block cannot exist: no command_hq_sessions
row, no transcript id, so `commanderBlock` returns null. Every later turn reuses the session and never
rebuilds the preamble. So a commander never received it at all — the "first turn gets no block" limitation I
closed with a rule was really "no turn ever gets it".

It rides `effectivePrompt` now: the string the model sees, as against `displayText` which the transcript
shows. Same seam the background-job dialog already uses for its hidden preamble, so nothing new — the agent
gets the paths without them appearing in the conversation. Once per session, on the first turn where the
block resolves, and it stays in the conversation's history from then on.

`briefedSessions` is in memory, so a restart re-briefs. That is the safe direction: a few hundred tokens on
one turn against a commander that was never told and therefore cannot be stopped.

The preamble path is removed rather than left dormant — a dead branch that reads as the mechanism is how this
went unnoticed in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:19:28 +00:00
pastilhasandClaude Opus 5 fc8ce04f2b the commander's name shows the moment it is typed, not after a refresh
Naming works — confirmed on a live commander, which now carries `{"type":"summary","summary":"Inbox
Summarizer"}` in its transcript. The instrumentation showed exactly the predicted shape: two attempts 2.8
seconds apart, the first on `session:claude` before the transcript file existed and the second on `result`,
which is the one that landed. The retry was the fix; the guess about WHY was right and the evidence was worth
having rather than assuming.

What was left was cosmetic and looked like the same bug: the pane still read "Untitled commander" until a
refresh. It could not have read anything else — the rename lands at the end of the first turn, and this bar
has no fetched title because a brand-new commander has nothing to resume. So it showed the one thing on
screen nobody had chosen, while the chosen name sat in a field two inches above it.

It takes the name from state now, which is where it already was.

Instrumentation removed. It earned its place: three attempts at this bug were reasoned and two of them were
wrong, and the log settled it in one run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:01:33 +00:00
pastilhasandClaude Opus 5 cb51f35cad deleting a conversation forgets what the platform stored about it
Found by a sanity check after two commanders were deleted through the UI: the Command HQ tables cascaded
perfectly and four OTHER tables kept a row each, for ever. 24 orphaned rows in chat_session_models, 26 in
chat_session_events, against 14 live transcripts.

Nothing cascades because there is nothing to cascade FROM. All four are keyed on a transcript id, and a
transcript is a file the harness writes — there is no row for a conversation, so the database cannot help and
the delete path has to be explicit. It was not.

Three of the four are inert: a model, a bell preference, a durable copy of turn output nothing will ask for
again. `nudges` is not — it is a SCHEDULE, read by the tick in nudger.ts. A nudge belonging to a deleted
conversation is a timer that keeps firing at something that no longer exists. That is what turned this from
untidy into worth doing now.

deleteClaudeSession returns the IDS it removed rather than a boolean, because a `/clear` chain is several
transcripts presenting as one conversation and forgetting the head while orphaning its parts is the same bug
at a smaller scale.

THE TRAP THAT CHANGE INTRODUCED, caught before shipping: every call site said `if (deleteClaudeSession(...))`
and an empty array is TRUTHY in JavaScript, so a delete that found nothing would have reported success. The
typecheck was clean — `string[]` is a perfectly good truthy expression. All three sites test `.length` now.
Fifth silently-wrong thing today that a typecheck could not see.

Cleared the existing orphans with the same function the delete path now uses, after checking the one thing
that would have made it wrong: whether any of them belonged to a member, whose transcripts live in a home
this scan does not read. There is one account on this machine, so they were all the owner's.

Two chat_session_events rows appeared again immediately after, and are not a leak: the durable log is written
independently of the transcript — that is what makes turn output survive officer being down — so a session
that has just begun legitimately has events before a transcript exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:54:23 +00:00
pastilhasandClaude Opus 5 a6e7f64ef2 todo: a commander should suggest the cheapest model for each half of itself
Wanted 2026-08-22, for later. Before arming anything, a commander weighs how hard the job actually is and
proposes which model each half should run on.

TWO models, which is what makes it more than a generic "use something cheaper" note. A commander's own turns
are conversation plus one piece of bash; the agent its loop spawns does the real work and its difficulty is
set by the job. The deploy commander is the worked example — its own turns were unremarkable on Sonnet while
the spawned agent judged six hosts, parsed a plan, decided what to skip and wrote an honest report.

Suggested, never imposed: the failure mode of choosing too small is not a slower answer, it is a watcher
firing on the wrong condition or an agent half-finishing a deploy.

Recorded with the reason it is not urgent: nothing measures whether the choice was right. A spawned agent's
cost is not captured at all yet — the column exists and is always null — so a commander suggesting Haiku has
no way to learn it was wrong. This wants the cost work first, or at least the knowledge that until then it is
a judgement with no feedback loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:39:26 +00:00
pastilhasandClaude Opus 5 f065e2c0f8 the disabled composer says why, instead of inviting you to type
Caught in a screenshot before any testing: the new-commander composer was correctly disabled and still read
"Type a message...". `placeholder` was declared TWICE on the same textarea — the reason I added, and the
original below it — and JSX takes the last one. So the gate worked and was completely invisible, which is
the worst version: an input that refuses you without saying what it wants.

One placeholder now, carrying the reason when disabled and the usual text otherwise.

Fourth prop-shaped mistake today, and a new variant: not a prop that does not exist, but a real prop
silently overwritten by a second copy of itself. A typecheck cannot see either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:35:42 +00:00
pastilhasandClaude Opus 5 1c47d0bba7 a commander is named and given a directory before it can be spoken to
The third of the three, and the one the owner will actually see. The new-commander card now collects both
decisions above the guide, and the composer refuses input until they are made — with the reason in the
placeholder rather than a validation message after the fact.

Both are chosen first because neither is cheap afterwards. The directory decides where every relative path in
every turn resolves and which project group holds the transcripts, so changing it later strands a
conversation away from its own history. The name has to reach the transcript the moment it exists, because
Claude Code names a new session from its first prompt and the platform's rename only wins by getting there
first.

The directory is a BUTTON, not a text field: it is an absolute server path, and typing one is how a commander
ends up running somewhere that does not exist. `DirPickerModal` already existed for /chat and browses the
account's own tree.

CAUGHT BEFORE SHIPPING, and only by checking: I first passed `cwd` and `commanderName` as `useChat` options
and `disabled` as an EmbeddableChat prop. None of the three existed. It typechecked anyway, because object
SPREADS bypass excess-property checking — `...(cwd ? { cwd } : {})` is invisible to it. That is the fourth
time today a silently-ignored prop has cost something, so the props are real now:

- `commanderName` is a `useChat` option on the send payload, and rides EVERY turn rather than only the first.
  The server applies it once; sending it again costs bytes. Sending it once would lose it if that turn failed
  and was retried — and a commander named after its second prompt is exactly what this prevents.
- `disabled` is a real EmbeddableChat prop carrying a reason, threaded to the textarea's NATIVE disabled —
  which also removes it from the tab order and blocks a paste, neither of which a guard on send would do.
- the directory goes to EmbeddableChat's existing `cwd` prop, because that is what feeds `sendPrompt`'s
  `cwdParam`. Two values chosen in one place travelling by two paths, noted where somebody would otherwise
  have to discover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:11:12 +00:00
pastilhasandClaude Opus 5 2bd8701b15 a commander runs where the owner chose, and Command HQ stops scanning one folder
Two of the three things the identity change unblocked.

THE WORKING DIRECTORY. `resolveChatCwd` forced `getCommandersDir(email)` and ignored `msg.cwd` entirely —
every commander shared one empty directory whose only purpose was to BE a cwd, because the cwd was how a
commander was recognised. It honours the choice now, resolved through the same `resolveCwd(home, …)`
containment the ordinary chat branch uses, so "client-chosen" still cannot mean "anywhere outside the
account's tree". No fallback to the old shared folder: the UI will require a directory, and an absent one
falls back to the account's home, which is the honest last resort rather than a silent special case.

THE LIST. Command HQ found its commanders by scanning that one directory, which would now find only the ones
that happen to still live there and silently hide every other. It reads its rows and loads each transcript by
id — `loadClaudeSessionSummaryById`, added here — a handful of lookups rather than a directory walk. A row
whose transcript is nowhere is treated as absent exactly as a missing file was.

`POST /purge` had the same scan and it was the dangerous one: unfiling every commander that chose a different
directory, from a housekeeping route, when the rows are what identity now depends on.

Three smaller corrections that came with it:

- rename and delete passed the commanders directory as their cwd argument. It is documented as a HINT that
  falls back to locating the transcript anywhere, so they worked — but passing a path we know is usually
  wrong reads as intent later. They pass `who.home` now.
- `POST /command-hq/commanders/new` returned `cwd: getCommandersDir(...)` regardless of where the commander
  actually ran, and now accepts `cwd` and `name` like the UI will, and reports what it was given.
- the router's "Where commanders live" section said every commander shares one directory and that listing is
  therefore a scan. Both sentences were the design being replaced.

`loadClaudeSessionSummaryById` takes its mtime from `listTranscriptsAs` rather than a bare stat: the file may
belong to a member, and reading a member's tree needs the as-user path. That is one scoped directory listing
per commander — the same call the old whole-folder scan made, once per row instead of once for all, which is
the price of letting each choose where it lives.

Verified on live data: the existing commander still resolves, with its title and its real updatedAt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:06:19 +00:00
pastilhasandClaude Opus 5 d08ae41f1f a commander can be named before it says anything
Claude Code will not accept a name for a new session — the harness names its own from the first prompt — so
the platform renames the transcript the moment it exists. The owner names the commander up front, beside
choosing its working directory, and never sees the rename happen.

It works because of what rename actually writes. `renameClaudeSession` stores a `summary` record, which is
what sets `hasExplicitTitle`, and an explicit title is never overwritten — not by the harness's `ai-title`,
not by the first-message fallback. So the chosen name wins permanently rather than racing the titling. That
property already existed and was documented; nothing needed adding to make it hold.

Applied SERVER-side, in `fileAsCommander`, on the `session:claude` that first carries a transcript id. Not by
the client, because it must not depend on a browser still being open: the name is chosen before the first
message, and losing it to a closed tab would leave a commander called after whatever it happened to say
first.

`chatIdentity` is exported and reused rather than reconstructed. It decides the home and the OS user, and a
rename writes a file as that user — a second copy of that resolution is the kind of thing that works until a
member uses it.

The wire field is carried on the turn rather than sent afterwards, for the same reason the plan-mode flag is:
a brand-new conversation has no live session to configure and no stored row to update, so its first message
is the first moment anything can be acted on.

UI comes with the directory picker — both are chosen in the same place, and neither is optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:54:11 +00:00
pastilhasandClaude Fable 5 a356656a60 hold bun at the 1.3 series until 1.4 has been watched
bun 1.4.0 was published 2026-08-20 and https://bun.sh/install serves
whatever is newest, so machine-setup provisioning a box today would put a
two-day-old release under the platform and every pm2 app except
officer-pty. The owner asked for a temporary hold on 1.3.

The SERIES is pinned, not a patch — BUN_SERIES=1.3, resolved to the newest
1.3.x at install time so bug fixes inside the series still land, and no
1.3.14 to rot into "the version we happened to pick" the way setup_22.x
did for Node. The version reaches the installer as its documented first
positional argument (a release tag), and install_bun refuses to run with
an empty one: taking whatever bun.sh serves is the single outcome the pin
exists to prevent, so no bun at all is the better failure. Loud, and
officer-setup's pre-flight already stops on a missing bun.

Resolved from the npm registry rather than GitHub's releases API, which
is the obvious source and the wrong one — 60 unauthenticated requests an
hour per IP, and it returned 504 twice while this was written. An empty
answer installs nothing, so where it comes from is load-bearing.

The section now reads four states instead of two. "Installed" was one
answer covering a machine inside the pin, one behind it, and one past it;
past it is the case worth catching, since that is what bun upgrade leaves
and nothing else reports it. Above and below get different prose and both
offer to move; nothing is corrected silently.

What a setup script cannot cover: bun upgrade takes no version argument —
latest stable or --canary, verified against its own --help — so it always
jumps past the pin without saying so. Hence the doctor check, which reads
engines.bun rather than repeating the number, because a second hardcoded
copy is a second thing to drift. bun ignores engines (verified: an install
declaring 1.9.x ran clean under 1.3.14), so the field is the declaration
and doctor is the enforcement. Confirmed bun install --dry-run still
resolves frozen with the field present and bun.lock unchanged.

This host is already on 1.3.14 and inside the pin, so nothing moved here.
Also fixes three more stale process counts in the same block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:45:26 +00:00
pastilhasandClaude Opus 5 1c7619e184 the permalink goes through the router, so the open chat learns its own name
Third attempt at this bug, and the first two were fixes to the wrong component. The /chat screen renders
ChatDetailPanel, not ChatHeader — so invalidating the sessions list and correcting which id the panel records
were both real improvements to a surface that is not the one showing "New chat".

The actual cause is one line. The permalink was written with `window.history.replaceState`, which changes the
address bar and tells react-router nothing. On that screen BOTH the title and the rename control come from
`useParams().sessionId` — see the note in DetailBar about renaming the chain's head rather than the tail — so
the router kept reporting no session, the title stayed "New chat", and rename stayed absent. Navigating away
and back forced the router to re-read the location, which is why that appeared to be the cure and why the fix
looked like it belonged in a list.

Now a `navigate(..., { replace: true })`. Replace rather than push, so Back still leaves the conversation
instead of stepping through the same one under a succession of names.

Worth recording for the next one: three separate "the UI did not update" bugs today were all a URL or an id
not reaching the thing that reads it — two of them the officer-key/transcript-id pair, this one the router.
`useQueryState` still writes `history.replaceState` directly; same trap, different hook, left alone rather
than widened into this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:44:11 +00:00
pastilhasandClaude Opus 5 3f1ba38f19 the chat panel's header names a new conversation too
The list learned the name and the panel header did not, which narrowed it to one lookup: the panel records
its session id, and ChatHeader resolves the title by finding that id in the sessions LIST.

The panel was recording `chat.sessionId` — OFFICER's session key — and the list is keyed on Claude's
TRANSCRIPT id. Two id spaces on either side of one `find`, so it never matched: a new chat's header read
"New chat" for the whole life of the conversation and offered no rename, because rename is a per-row action
on a list the session was not found in. Selecting the conversation from the list fixed it, which is exactly
why navigating away and back looked like the cure.

Same trap as the Command HQ pane on 2026-08-21, third time this pair has caused a bug: officer's handle and
the harness's transcript id are not interchangeable, and every place that keys one lookup on the other fails
silently rather than loudly.

The "only a REAL id, never null" rule is unchanged and still needed — both ids are null for a moment while a
resumed session connects, and writing that null would turn "this panel has never chosen" into "the user asked
for a new chat", so the next reload would open blank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:35:09 +00:00
pastilhasandClaude Fable 5 577ebde902 the services section counts its processes instead of saying six
CORE_PROCESSES grew to seven when caldav and notify became core on
2026-08-19, and five hardcoded "six"/"6 processes" claims around it did
not move. Derived from the array now, so the next core process cannot
re-create the drift. Same class of rot in preflight's tool_why, which
still said bun runs "nineteen of the twenty processes" — a count from
the pre-plugin era, replaced with a count-free sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:30:37 +00:00
pastilhasandClaude Opus 5 59f600d34d a new chat gets its name without a refresh — this time with the import
Re-lands f9a36996, which I reverted because it broke chat rendering outright:
`ReferenceError: useQueryClient is not defined`.

The cause was mine and it is embarrassing in a specific way. The edit that added the import searched for
`import { useCallback, useEffect, useMemo, useRef, useState } from 'react'` and the file's first line is
`import { useState, useEffect, useRef, useCallback } from 'react'` — different order, and useMemo is not in
it. So the search matched nothing, and I had used replace WITHOUT an assertion, so nothing said so. That is
the identical failure I caught and wrote up hours earlier in the CommandHQ layout edit, where two edits
silently did nothing for the same reason.

Every edit in this commit asserts. Which then caught a second, subtler fault: `commitStreaming()` appears
ELEVEN times, so anchoring the turn-end call on it put announceSession inside `assistant:text` rather than
`result`, with mangled indentation. An assert proves an anchor EXISTS; it does not prove it is unique. The
result handler is now anchored on a three-line sequence that occurs once, and the count is asserted rather
than assumed.

Verified in the BUILT BUNDLE rather than by typecheck, because a ReferenceError is a bundle fact: the name
appears zero times as a free identifier (the bundler resolved and renamed it), and both invalidation keys are
present — CLAUDE_SESSIONS went from one occurrence to two, the second being this.

Worth recording about the tooling, since I doubted it: tsgo DOES catch this class of error. I probed it three
times with a deliberate type error in this exact file and it reported every time. So the check is sound and
the miss was mine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:26:15 +00:00
pastilhas cbedfed2ba Revert "a new chat gets its name without a refresh, and can be renamed"
This reverts commit f9a3699603.
2026-08-22 18:18:54 +00:00
pastilhasandClaude Opus 5 f9a3699603 a new chat gets its name without a refresh, and can be renamed
Start a chat and the header said "New chat" until you navigated away and back, and the rename control was
missing entirely. Both halves came from one cause, and it was not the titling.

The header resolves its own title by finding the session in the cached LIST —
`sessions.find((s) => s.id === sessionId)` — and a newborn conversation is not in that list. It has
staleTime 30s and nothing invalidated it, so `current` was undefined: the label fell through to "New chat",
and rename was absent because rename is a per-row action on a list the session was not in yet. Navigating
away and back refetched it, which is exactly what made this look like a rendering bug.

Verified first that the titling itself is fine and needs no change. Claude Code writes an `ai-title` entry
early — line 8 in a real transcript, after the user message and BEFORE the first assistant line — and
Officer's own `title: summaryTitle || firstUserText || '(untitled)'` means a session is never nameless once
its transcript has a user message. The server could always answer; nothing asked it.

So the conversation announces itself. On `session:claude` — the START of the turn, when the transcript
becomes real on disk — and again at the end of every turn, because the harness may have bettered the name
while it ran. The second call sits OUTSIDE the id check deliberately: that is true whether or not a
particular `result` happened to carry the id.

Cost is one small request per turn per open chat, which is the honest price of a list that is right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:15:20 +00:00
pastilhasandClaude Opus 5 75affec091 a commander is identified by its row, not by where it lives
Groundwork for choosing a commander's working directory. Identity was the CWD — every commander shared one
directory, so the directory was the answer and it needed no query. The moment a commander can choose its own,
that test is wrong in BOTH directions: a commander in the platform checkout reads as an ordinary chat, and an
ordinary chat in the old shared directory reads as a commander.

So identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
The isolation the shared directory provided is no longer carrying anything — the surfaces that needed it now
filter explicitly, which is what made this possible.

Four surfaces moved, and the shape of the change is the same in each: the transcript scanner stops inferring
it and the ROUTE decorates from the rows.

- `listClaudeSessions` says nothing about commanders any more. It is synchronous over a directory scan and
  identity now needs a database read, so `withCommanderFlag` applies it at the caller.
- the sessions list, `/chat/live` and `GET /chat/sessions/:id` each read `listCommandHqSessionIds` once per
  request. A set membership test per row rather than a lookup per row: commanders are few, and the
  alternative turns one list into fifty queries.
- `commanderBlock` resolves the row instead of parsing the account out of the path. It needs the email for
  the record paths, so that is a second read — once per session spawn, beside the affordances and the skills
  index, which are read there for the same reason.

`commanderBlock` had to be hoisted out of `createSession`, which is synchronous, into `spawnClaudeStreaming`,
which is async and already holds the account. Passing it in as a parameter rather than making the whole
session constructor async.

`isCommandersDir` survives but is documented as no longer being the identity test — it remains only for the
places that legitimately care about that specific path: creating it for a new OS account, and as the
historical home of commanders created before this.

A commander mid-first-turn has no row and so reports false for one turn. That is the same turn it must not
arm anything on, and it corrects itself the moment it is filed.

Verified against real data: a filed commander renders the block, an unknown id and a null id both return
null, and the live gate still answers 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:07:00 +00:00
pastilhasandClaude Fable 5 de9d229cfa todo: review the secret store built overnight
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:02:55 +00:00
pastilhasandClaude Opus 5 cabc4825ef a commander never arms on its first turn, and the missing paths are how it knows
The first-turn gap is closed by a rule rather than a mechanism, and the two halves interlock instead of
conflicting.

A commander cannot be told its record paths on the very first turn: the transcript id is minted as that turn
begins, so there is no id to key a path on. That was written up as a limitation. It is not one, because
arming on the first turn is not permissible anyway — the first exchange is for agreeing what to watch and
what to do about it, and a watcher armed before that agreement is armed on a guess. A wrong condition fails
silently in both directions: never fires, or fires constantly, discovered days later.

So the absence became the signal, and the block says so: "If you were given no paths above, you ARE on your
first turn: that is what their absence means." An agent with no paths is on the turn it must not arm on, and
an agent that armed anyway would be unstoppable and undeletable — the same rule reached from the other side.

The rule is in both places on purpose. The block, always loaded, carries the instruction and the interlock in
four lines. The Commanders skill carries why, at length, for the agent that is about to arm one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:54:47 +00:00
pastilhasandClaude Opus 5 87ff6ed326 a commander is told where to record itself, however its loop gets started
The record instruction lived in one place: the preamble the background-job dialog composes. Fine while the
dialog was the only way to arm a commander, and wrong for the flow being built — the commander arming itself
from the conversation, with the owner never touching the launcher. An agent starting its own background job
never sees that preamble, so it would record nothing, and delete would quietly go back to leaving orphaned
loops.

So the paths are compiled into a commander's session preamble, beside the affordances and the skills index.

THE SPLIT, which is the part worth keeping: the METHOD lives in the Commanders skill — read on demand, as
long as it needs to be — and the PATHS are compiled in always, because they are a fact about this session
that the agent cannot derive. That is exactly the line between a skill and an always-loaded block, and it
falls in the same place here as everywhere else.

The account is derived from the CWD rather than passed in, because the caller does not have it: the sidecar
knows a member's osUser and home, and an owner's turn carries no email at all. A commander's cwd is
DATA_PATH/<email>/commanders, so the email is in the path — and it is verified by reconstructing the
directory from it, so a coincidentally-shaped path cannot pass.

Keyed on the cwd rather than a database lookup, for the same reason every other surface uses
isCommandersDir: a session must not be a commander in one place and not another. It also works before the
row exists, which a lookup would not — and the first turn is when the row does not exist yet.

Stated in the file rather than left to be found: a brand-new commander's FIRST turn gets no block, because
the transcript id does not exist until that turn begins. In practice the first turn is the conversation about
what to watch and the loop starts on a later one, but a commander that armed itself on its opening message
would not have been told where to record.

Verified on the three cases: a real commanders cwd renders the block with both paths, an ordinary chat gets
null, and a session with no id yet gets null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:47:55 +00:00
pastilhasandClaude Opus 5 6d6e8a841e spawned children are database rows, linked to their commander
The file was doing a job it is not good at. A newline-delimited list appended by a shell cannot express a
link, cannot be queried, cannot be updated in place, and cannot hold a fact that arrives later — and a
child's cost arrives later, from the child, long after the loop wrote its id.

So the file becomes an INBOX and command_hq_spawned becomes the record.

The constraint that shapes it: the loop is bash and cannot reach Postgres. Giving it a credential so it could
would put an authenticated writer inside a background job, which is worse than the problem. So the loop
appends with a shell redirect and the platform ingests — idempotently, on the unique index, which is what
makes ingest-on-READ safe. A cron for this would be a moving part that can stop without anybody noticing;
the read is the only moment the answer is wanted.

What the rows buy that the file cannot:

- the link, ENFORCED. onDelete cascade means deleting a commander cannot leave an orphaned child row, which
  is the reason this became important.
- queries. Cost per commander, how many agents a trigger spawned, which are still running.
- somewhere for finishedAt, totalUsd and outcome, which the loop cannot know when it appends.

totalUsd is NULLABLE and null means unknown, never zero. The children endpoint returns `unknownCost` as a
count beside the total, because a commander whose children's costs are unknown must not display a confident
total that happens to be too low — that understatement is the whole reason this exists.

Delete ingests before reading, so a child appended since the last read is still deleted: the file is allowed
to be lossy, the rows are not, but a row never ingested does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:26:55 +00:00
pastilhasandClaude Opus 5 b7112f6e5d a commander records what it is and what it spawned, so deleting one means it
Without this record a commander cannot be stopped or fully deleted, and the delete route's comment claimed
otherwise: "the running background process (if any) dies on its own". It does not. Nothing in that route
touched a process, so a deleted commander left its loop running — still polling, still able to fire, still
able to spawn a deploying agent, with no chip to stop it from and no row left to disarm.

THE RECORD, and why the loop has to write it. Three ways to recover the link were tried and none works: the
PROCESS holds nothing (no open transcript descriptor, nothing identifying in its environment — tested); the
TRANSCRIPT is indistinguishable, because queue-operation/ai-title/last-prompt look like platform markers and
are the harness's own, appearing in every session equally; CONTENT matching finds ordinary chats that quote a
procedure as readily as the agent following it. So the loop records, because it is the only party that knows.

Two files under DATA_PATH/<email>/commander-state/<session>/: `loop-pid` and `spawned`. The reader is
deliberately tolerant — a truncated write or a stray line is dropped, not fatal, because a record that failed
closed would make a commander undeletable over one bad line.

STOPPING needs no new mechanism, only the pid: descendantPids + SIGTERM from the loop takes the loop and any
agent under it, which is exactly what that helper was written for. A commander with no recorded pid stops
nothing and SAYS so — the honest answer for one armed before this existed.

DELETE now cascades in a deliberate order: stop the processes, then the children's transcripts, then the
commander. Stopping first because a live agent writing to a transcript being deleted is a race, and because
the loop could otherwise spawn a replacement between the last two steps. It returns counts rather than a bare
ok — this is the one irreversible action here.

The children's transcripts are deleted with the EXISTING deleteClaudeSession: its cwd argument is documented
as "a hint, not an authority", so it locates a child that ran in the platform checkout rather than the
commanders directory. No new function.

THE CHAIN'S WEAK LINK was that the loop cannot know which commander it belongs to. The paths are now handed
to it in the background-job prompt, fetched from a new endpoint because they are rooted in DATA_PATH — a
server fact. Paths rather than an upload endpoint on purpose: a shell redirect needs no credential, and a
background job has no business holding one.

The prompt rule is numbered properly rather than landing on 7.5, and the output rule shifts down one when it
is present. Verified both shapes produce consecutive numbers.

The manager now exposes claudeSessionId, because a commander is keyed on the TRANSCRIPT id everywhere and
officer's own session handle is not interchangeable — that mismatch is what once rendered a working commander
as an empty pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:18:30 +00:00
pastilhasandClaude Opus 5 ee9106ef54 the new-commander card gets a slot for example prompts
Requested, and deliberately not written: an example opening prompt is a promise about what the flow accepts,
and the flow is being rebuilt. Written now, all three would need rewriting — and a wrong example is worse
than none, because a user copies it and blames themselves when it does not work.

The note records what they should be when they are written: two or three differing in SHAPE rather than
subject — one where the trigger is obvious, one that needs a conversation to pin down, and one that is
plainly not a commander at all, so the boundary is visible from the examples rather than only from prose.

Marked provisional, so it renders with the draft badge until somebody writes it. That is what the flag is
for: this ships on every deploy whether the copy is ready or not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:53:39 +00:00
pastilhasandClaude Opus 5 e4f1b75f9b a platform skill the agent can open, generated so it cannot rot
A fresh agent answering "what is a commander, how do I start one" has nothing to answer from, and invents.
The mechanism to fix that already existed and had one user: skills/load.ts compiles an INDEX into every
session — name, when to reach for it, path — and the body costs nothing until the agent opens it. The exact
inverse of an affordance, which sends its body always and its description never.

So no affordance was added, and that is the correction worth recording: an affordance saying "there is a
platform skill" would be a second copy of the index entry, paid for on every turn to say what is already
there. Affordances are facts about the CLIENT; this is reference material.

The platform skill is GENERATED, because a hand-written one rots and we measured it — Spotlight's settings
list drifted in two days, CLAUDE.md claimed 27 permissions against 18 and twenty PM2 entries against
thirteen. A wrong skill is worse than a wrong doc: an agent quotes it with confidence and the user cannot
tell.

Generated: every permission with its kind, label, description, API prefixes, sockets, screens, self-service
routes and read-only-writes; and the keyboard shortcuts. Prose, kept beside the generator so the two cannot
separate: what the platform is, what each KIND means for a user, and where to look next.

It also says what it does NOT cover — installed plugins vary per machine so a generated list would be wrong
on every other server, and request shapes are not in the registry — plus a closing instruction not to guess,
on the grounds that a question the registry cannot answer is one this file cannot either.

docs:check regenerates it into memory and fails if the committed file differs. Verified both directions:
appending one line made it fail naming the file, and regenerating made it pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:48:31 +00:00
pastilhasandClaude Opus 5 4a206086af docs:check — the documentation's checkable claims, against the code
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
invisible. Every piece of drift found today was mechanical — a count, a route name, a process list — so it is
checkable, and a check that exits non-zero cannot be quietly not done.

It found two things on its first run, both wrong for over a week:

  platform/CLAUDE.md    "27 entries as of 2026-08-13"    the registry holds 18
  officer-dev/CLAUDE.md "Officer is twenty PM2 entries"  there are 13

The second was worse than a wrong number. It listed eleven processes that do not run here — vault, slskd,
headscale, transmission, invoiceshelf, wallet, photos, jellyfin, gitea, music, vnc — while omitting six that
do, and named two mybiblepal entries as sharing the daemon when nothing does. Accurate on 2026-08-07, and
nobody re-derives a list of processes while editing the sentence around it. Both are corrected, with what
they used to claim recorded, because the interesting part is how a maintained list decays rather than that it
did.

Three checks: the permission registry count, the PM2 entry count, and every settings route Spotlight offers
resolving to a section some screen declares. That last one is the class the counts cannot catch — a name, not
a number — and it is the drift that shipped a dead link to /settings/users.

THE RULE FOR ADDING ONE, written into the file: a check compares a documented claim against a value DERIVED
AT RUN TIME, never against a second hard-coded copy. A check with the answer written into it is one more
place to drift, and it drifts in the same edit that misses the doc.

What it deliberately cannot do is read prose and judge it. It looks only at claims with a machine-checkable
counterpart — a small fraction of the documentation, and the fraction that rots fastest.

The settings check parses source rather than importing it, and says why: those sections are object literals
inside components, so importing them drags React and a DOM into a CLI.

`skipped:` lines are printed rather than swallowed — a machine without pm2 cannot check that count, and
saying so beats reporting a pass it did not perform.

Recorded as required before tagging in CLAUDE.md, and in prompts/deploy-commander.md as explicitly NOT the
rollout agent's job: it executes a plan, and a prose edit between two hosts is not that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:42:15 +00:00
pastilhasandClaude Opus 5 29d998b6c3 a new commander pane explains itself, in skeleton
Starting a commander currently means knowing to open the background-job dialog and arm a watcher from it.
That is not discoverable, and it reads as configuration when the real answer is "talk to it" — so the dialog
stops being the way in, and the pane explains the way in instead.

EmbeddableChat's own empty state is "Send a message to start". True, and it tells a first-time user nothing
about what to send.

The copy is NOT written, by agreement: the instructions have to describe a flow that does not exist yet, and
writing them first produces text that is wrong in a way nobody notices. What is here is the structure, plus
the parts already true whatever the flow becomes — what a commander is, and that it begins as an ordinary
conversation.

Sections carry a `provisional` flag that renders a visible "draft" marker. That is the load-bearing detail:
this ships on every deploy whether the copy is ready or not, so a half-written guide must not be able to
look finished. Clearing the flag is the last step of writing a section, not a formatting choice.

Data rather than JSX, so filling it in later is editing one array instead of hunting through markup — the
same shape as the shortcuts registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:30:11 +00:00
pastilhas 228f2f4638 deploy report 2026.08.22.02 2026-08-22 16:20:11 +00:00
pastilhasandClaude Opus 5 68d60e409b 2026.08.22.02 — theme favourites, ctrl+? shortcuts, command hq in two columns
Schema change this time: theme_favorites is new, and without the push the theme dropdown fails on a missing
relation the first time it reads a starred list. The plan names db:push as a step rather than leaving it to
be inferred from a diff.

Two things the plan states that a reader could not infer. Command HQ MIGRATES a saved layout on load, so
anyone who has opened that screen sees their arrangement replaced once — deliberate, and the screen is
locked so the only loss is column sizes. And the email plugin's multi-account work arrives through
plugins:update from another repository, affecting only hosts where that plugin is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:10:51 +00:00
pastilhasandClaude Opus 5 b1dfa3f7bb spotlight on shift+space, overriding both of its stock behaviours
Fifth setting in three days: ctrl-space → shift-space → alt-space → ctrl-space → shift-space. Ctrl-space was
tried twice on the theory that preventDefault would beat Brave's address bar. It does not — that binding is
at the browser level and the key never reaches the handler. Recorded in the file so it is not tried a third
time.

Shift-space overrides two stock behaviours deliberately, and the owner's reasoning is the right one: it types
a space in a text field, and it scrolls a page UP when nothing has focus. Neither is worth a launcher.
Page-up-by-shift-space is a fossil, and typing a space is what the unmodified spacebar is for.

The cost is stated once in the file rather than argued again: typing in CAPS by holding shift instead of
using caps lock puts a shift-space between every word, which opens the launcher mid-sentence. If that turns
out to bite, the fix is a text-field guard — which this had for one day, and which is exactly what made the
launcher unopenable from the composer, where it is most wanted. Trading that back would be trading down.

Every other modifier must be absent: ctrl+shift+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix. Verified disjoint in the built bundle, both conditions
quoted from it rather than from the source.

Five moves in three days is the argument for making chords configurable rather than for choosing better ones,
which is why the ctrl+? registry names the implementing file for each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:05:59 +00:00
pastilhasandClaude Opus 5 f586d748d6 ctrl+space opens spotlight, and ctrl+? tells you everything else
Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space →
ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction —
alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and
would rather move the app than the window manager.

ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made
before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than
rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches
the handler and nothing in this repository can help — the fix would be in Brave.

`!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the
two conditions are disjoint.

CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls
declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose
help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and
the value is in state/shortcuts.ts.

The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own
conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the
pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now
would rewrite three handlers for nothing visible.

Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by
name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the
UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the
theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:57:42 +00:00
pastilhasandClaude Opus 5 bcdc769083 commanders are one per line, not a grid
sm:grid-cols-2 xl:grid-cols-3 was reasonable when this panel owned 34% of the screen. It owns part of a 24%
column now, so two columns of card inside it left each one too narrow for its title — every card truncated
and the cost line wrapped.

A grid of one. The card is untouched: same rounded pill, same border and hover, same p-4, same gap-3 between
them. It simply stops being asked to share a row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:41:27 +00:00
pastilhasandClaude Opus 5 430f058a6d command hq: two columns, with the navigation stacked
It was 16 / 34 / 50 — a category column, a commander column, and the transcript. So half the screen was
navigation for a list holding a handful of commanders (one, on this machine) while the only thing anybody
reads got the other half.

Now the two navigation panels are stacked into one 24% column — commanders on top, categories beneath — and
the transcript takes 76%.

Categories BELOW the commanders, not above: the commanders are what you came for and the categories are how
you narrow them. Above, the filter would push the list down the screen and be read first; below, the list is
at the top where a list belongs, and the categories can grow their own scrollbar without taking room from it.

The categories panel lost seventy pixels of branding — a Radar tile, "Command HQ", "Watchers &
long-running jobs" — which made sense when it owned a column and does not now that it sits at the foot of
one. The screen's own header says Command HQ two inches above it.

`All commanders` and `Uncategorised` moved INTO the scroll box with the categories. They are the same kind of
thing, a filter over the list, and splitting them into a separate nav block above made the second look like a
different mechanism. The heading and the + button stay pinned; only the list scrolls — the same rule as the
theme dropdown.

MIGRATION, and it is the part that could have gone wrong. This key is persisted per account, so the default
alone moves nobody who has opened the screen. No appType was retired either, so `hasAppType` cannot see it
the way the /chat migration could — the test is structural instead: a horizontal root with three children is
the old shape. It terminates because the replacement has two, and replacing outright is safe on a `locked`
screen where the only user contribution is column sizes. Same reasoning, and same trap, as
ChatHistory/index.tsx.

Two of my edits silently did nothing first, because I searched for `officerdb` where the module is
`officerdev` and used replace without an assertion. Both are asserted now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:38:41 +00:00
pastilhasandClaude Opus 5 9b459b79dd poll gitea locally — the public door is rate-limited and gitea is on this machine
The deploy watcher kept logging `Connection closed by 77.42.39.155 port 22` followed by "make sure you have
the correct access rights", which reads as a credentials problem and is not one. `origin` pointed at
gitea.officer.dev, which resolves to the public address and arrives through Nginx Proxy Manager — where
every connection appears to come from one container IP, internet bots get that IP penalised by
srclimit_penalise, and SSH is refused for long stretches.

Gitea is a plugin ON THIS MACHINE, listening on 100.64.0.13:10023. The watcher was leaving the box and
coming back in through a proxy to read a repository sitting on its own disk.

origin now points at the local address; the public URL is kept as `gitea-public` so nothing is lost. No file
was edited to do it, which matters — the watcher is a running bash script and editing one in place is how a
loop starts executing half of two versions. Changing the remote's URL takes effect on the next tick with
nothing restarted.

Loopback would be better still and is not used: 127.0.0.1 fails host key verification because it has no
known_hosts entry, while the tailnet address is already trusted.

The cost of not knowing this, recorded in the prompt: a tag pushed at 21:47 on 2026-08-21 went unnoticed
until 23:07. Eighty minutes, forty-odd ticks, every one of them correctly logged and retried. The loop was
never at fault — it was asking through a door that is sometimes shut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:27:20 +00:00
pastilhasandClaude Fable 5 ab77c66f04 todo: configurable shortcuts, a shortcut modal, and the omarchy menu revamp
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:24:26 +00:00
pastilhasandClaude Opus 5 dd6135cac1 favourite a theme from anywhere, and the browser stops fighting the menu
Three reported faults and two additions, all from one keystroke's worth of use.

THE MENU OPENED BEHIND THE OVERLAY. The theme pill is a Radix DropdownMenuTrigger, and a focused trigger
opens on Space — so ctrl+shift+space opened the browser AND the dropdown behind it. preventDefault could
never have fixed it: React attaches its handlers at the root, which bubbles before window, so the trigger
saw the key first whatever this listener did with it. The listener now runs in the CAPTURE phase and stops
the event dead.

THE VIM KEYS DIED WHEN OPENED FROM AN INPUT. Same shape as the Spotlight bug earlier tonight: the guard
that refuses to steal keys from a text field was still true after the overlay opened, because focus had
never left the field. Opening now blurs, and the guard no longer applies once the list is open — there is no
input in it, so there is nothing left for it to protect. Only the GLOBAL bindings defer to a text field now,
which is the case it was written for.

f STARS WHAT IS FOCUSED, inside the list, so a browse and a shortlist are one pass rather than two.

ctrl+shift+f STARS THE CURRENT THEME FROM ANYWHERE, and deliberately without the typing guard the arrows
beside it need: word selection is what ctrl+shift+←/→ collides with in a text field, and ctrl+shift+f
collides with nothing — no character, no browser binding. Guarding it would only break it in the composer,
which is where you are when a theme starts annoying you.

The header pill now shows a star when what you are wearing is one of yours — only when true, because a
hollow star on every theme is chrome that says nothing two hundred times.

The row star in the overlay is a span with role="button", not a button: it sits inside the row's own
<button>, and nesting one is invalid HTML that browsers resolve by dropping one of them.

useThemeKeys takes the toggle as a callback rather than reaching for it. Favourites are per account and live
behind an API, which is app knowledge; this hook owns keys and selection and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:15:36 +00:00
pastilhasandClaude Opus 5 bd15a186b5 themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording.

ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together
in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it".
Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which
is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the
ctrl+shift+space browser, or one teaches an order the other contradicts.

FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a
DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would
lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are
directories discovered at request time — so a star can outlive its theme and the reader intersects with the
live registry rather than trusting the table.

Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer
"what else is there". Filtering to favourites only is a separate, explicit toggle.

THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line
telling you the shortcuts exist was visible for half a second and then gone under two hundred entries —
which is the same as not having written it. Header and footer are now siblings of a scroll box instead of
passengers in it, and the header names alt+space too.

THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the
sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500,
which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account`
permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as
a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it
away from, and caught that by reading rather than by testing.

Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200
so the login screen keeps its colours.

10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in
the original survives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:51:53 +00:00
pastilhasandClaude Opus 5 a15fadbddc 27 tests on the file standing between a member and a shell
authorize.ts had none, and its own header is what says why that mattered: every deny path in the platform
ends there, and the failure mode of a permissive default is not a bug report, it is someone else's session.

It had none for a structural reason rather than an oversight. Every path needed two database calls, and the
only file here that mocks officerdb leaks its stub into whatever runs after it — so the obvious approach
would have traded one untested file for a flaky suite.

So the decisions are split from the fetching. resolveEffectivePermissions, decideApiRequest and
decideWsProvider are pure functions of their arguments; the async wrappers keep the role cache, the
fail-closed catches and the shape callers use. No behaviour change, and the live gate still answers 401 on
file-browser, users and tasks.

REAL registry keys throughout, deliberately: a suite built on invented permissions keeps passing after a
rename, which is the failure an authorization table can least afford. If a key here disappears this file
goes red.

Pinned beyond the happy paths — the owner carries no grants to consult; core is write unconditionally; a
confined grant is dropped without a Linux user and all three drop together; execution and admin grants in
the table are ignored even though no CHECK stops the row existing; an unknown key is ignored without
disturbing what is legitimately held; self-service is exact in method AND path; a socket needs no level; an
unauthenticated caller is refused every door.

Two gaps are stated in the file rather than left to be found: ownerOnly has no declaring permission yet, so
testing it would test the fixtures; and the grant cache's contract is a claim about call sites, not logic.

Found while writing them: readOnlyWrites paths are relative to the permission's api prefix, and getting that
wrong fails as "no permission covers this path" rather than as a level error — a misleading message for a
misleading path, now commented at the test that hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:42:56 +00:00
pastilhasandClaude Opus 5 7e054ddf11 deploy report 2026.08.22.01 — 6 of 6 hosts, officer-dev skipped
Rolled deploy/2026.08.22.01 to alpha, fofos, macbookpro, edge-pertento-bridge,
production-pertento-bridge and deadpool-rezio-bridge. Every host fast-forwarded
98177c3b -> 78db9c8c, ran plugins:update, restarted officer, and came back online.

No bun install and no db:push, as the plan required.

officer-dev skipped as the commander's own host, and already on this code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:30:15 +00:00
pastilhasandClaude Opus 5 0c2ff88c88 a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat
rather than navigating to Command HQ — right, and it cost the composer its commander controls, because
`isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander,
rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a
watcher wants, and the one the dialog makes default inside a commander.

GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same
isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation.

The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed.
The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route
to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only
would have broken the brand-new composer, which is where the whole thing starts.

Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and
isCommander=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:29:35 +00:00
pastilhasandClaude Opus 5 c982353381 the boot check now reads the sockets that are served, not a list beside them
assertPermissionTotality exists to refuse a surface nobody has gated, and it was reading the wrong list:
Object.keys(handlers) — the map of implementations — while Bun served a hand-written route table twenty
lines away. The comment beside it said neither could be a stale copy of the other. That was false, and it
had a live instance: /api/cliamp/ws and /api/cliamp/audio/ws stayed in the route table for two days after
their handlers and registry claims were commented out. Not exploitable — isWsProviderAllowed finds no
permission and 403s a member, while the owner upgrades onto a dead socket — but precisely the drift the
guard is for, invisible to the guard.

Now there is one declaration. WS_ROUTES maps path → provider, the route table is generated from it, and the
check reads it. A socket route cannot exist without being checked.

Two smaller guards arrived with it. A route naming a provider with no handler refuses to boot, with the path
in the message, instead of 500-ing on first connect. And the provider union that was inline in upgradeWs is
named, so the route table typechecks against what that function can actually serve — which caught my first
attempt, where WS_ROUTES was Record<string, string>.

sidecar and dev-proxy are listed explicitly as served-but-absent-from-WS_ROUTES, because they upgrade
through their own handlers rather than upgradeWs. Named so the absence cannot read as an oversight.

Half the TODO was stale and it is recorded as such rather than quietly closed: the eight red tests it cites
have been green since cliamp was deleted on 2026-08-15. Deleting cliamp removed the symptom and left the
shape, which is the more interesting half.

Verified against the real prefix lists rather than by reasoning: the served set passes, one unclaimed
provider is refused by name, all five generated routes answer 401, and the server boots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:25:48 +00:00
pastilhasandClaude Opus 5 78db9c8c93 2026.08.22.01 — 349 themes, spotlight, prompt history, email multi-account
No schema and no dependency change, verified against the previous tag rather than assumed. The last release
needed a push for anthropic_accounts and this one needs none, so the plan says so explicitly — a host that
runs db:push here has run something the release did not ask for.

Two things the plan states that a reader could not infer from the diff. The email plugin's multi-account
work is in ANOTHER repository and arrives through plugins:update, affecting only hosts where that plugin is
installed. And officer-dev is skipped as always but is NOT behind this time: it was restarted repeatedly
through the night as the work landed, so it is already running the tree this tag names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:20:18 +00:00
pastilhasandClaude Fable 5 ff133f88ff todo: settings as portable files, per user and per server
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 04:19:40 +00:00
pastilhasandClaude Opus 5 8e181ddf3c any key after a recall ends it, and Left is the one that proves the point
A recalled prompt you have started moving around in is a prompt you have adopted, so the next Up must not
walk further back over it — the same fault as recalling over a fresh draft, arrived at from the other side.

Left and Right are why this cannot be inferred from the text. They change nothing, so any rule watching for
edits would miss them entirely, and arrowing into the middle of a recalled prompt is the clearest statement
of intent available: you are working on this one now.

So the composer leaves the history on any keystroke that is not Up or Down. Bare modifiers are excluded —
holding Shift to capitalise the next letter is not editing, and treating it as such would end a recall
between two presses of Up. `Dead` is in that set too: a pending accent on a composing layout is not a
finished keystroke.

leave() clears the stashed draft as well as the position. That draft belonged to the composing session being
left, and handing it back after you have started editing something else would paste an older draft over a
newer one.

Six tests, three of them on the interaction between this and the emptiness rule from the commit before —
together they are what stops Up from eating an edited prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:15:08 +00:00
pastilhasandClaude Opus 5 02df4ac61c prompt history only takes the arrow key from an empty composer
Up replaced what you were typing. The guard was a caret-position test — Up only recalls from the first line
— which is correct for a multi-line draft and useless for a one-line one: the caret IS on the first line, so
every check passed and a half-written thought was swapped for something from last week. A draft is not
recoverable from anywhere and the history is, so the tie goes to the draft.

"Only when empty" alone would have made the feature useless, though, and that is the part worth writing
down: once a recalled prompt is in the box, the box is not empty, so an emptiness test allows exactly one
step back and then refuses. usePromptHistory already knew the difference — indexRef null means composing —
it just never told anyone. It now exposes isRecalling(), so the rule is "no text OF YOURS" rather than "no
text".

Whitespace counts as empty: a composer holding two spaces is not a draft anybody would miss, and an
invisible character silently blocking Up is worse than losing it. A draft that merely STARTS with
whitespace is still protected.

The line tests stay on top of it — in a recalled prompt spanning three lines, Up from the middle moves the
caret, because there is a line above to go to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:03:03 +00:00
pastilhasandClaude Opus 5 a5f387929d spotlight takes focus, and its arrow keys no longer depend on getting it
The arrow keys sometimes did nothing when the launcher was opened from a text field, and the two halves of
that are one bug: navigation lived on the panel input's onKeyDown, so every keystroke was conditional on
focus having actually landed there. When it had not — the composer kept it — ArrowDown went to the textarea
and the launcher never saw the key.

Two fixes, because either alone leaves the other half.

Opening now blurs whatever had focus, explicitly, before the overlay mounts. The effect that focuses the
panel's own input was already there and is enough on paper; "on paper" is what produced an
intermittent bug, so the outgoing element is blurred rather than assumed away.

And navigation moved to the window listener that already handled Escape, so it works regardless of what has
focus — a panel that grabbed it back, a blur that lost a race, an element with its own key handling. The
input's copy is deleted; leaving both would move the cursor twice per press whenever focus did land.

The handler reads `results` and `active` through refs, deliberately. The listener registers once per open,
so closing over them would pin it to their values at registration — arrowing down would read a stale list
and Enter would open whatever was first when the panel appeared. Putting them in the effect's deps instead
re-registers the listener on every keystroke, which is the same bug in a costume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:00:45 +00:00
pastilhasandClaude Opus 5 e4117441b0 spotlight had list metrics, and it is not a list
It read as cramped because the first pass reused the app's own list sizing — a 7px tile, text-sm, py-2 —
which is right for a row inside a panel competing with everything around it, and wrong for a modal you look
at for two seconds with nothing else on screen.

So the search input leads at text-base with a 5px glyph, rows are py-2.5 with a 9px tile, and the label is
text-[15px] with the hint and the route kept deliberately subordinate rather than scaled with it.

Two knock-on changes that are arithmetic rather than taste. The panel widened 560 → 640, because a row now
carries a label, a hint and a route on one line and the route is the first thing squeezed out — it is also
what disambiguates two rows with the same name. And the results box grew 52vh → 60vh, because taller rows in
the same box show fewer results, which would have quietly undone removing the eight-item cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:56:01 +00:00
pastilhasandClaude Opus 5 7671368faf alt-space opens spotlight from a text field too
The focus guard was still there. I moved the trigger to alt-space, wrote that the guard was no longer
load-bearing, and then kept it anyway "as belt-and-braces" — which left the exact bug the move was meant to
fix. Focus is usually in the chat composer, so the launcher did not open from the place it is most often
wanted. Reported one commit later, by the owner, from a text field.

The guard existed because shift-space types a space and eating that keystroke was unacceptable. Alt-space
types nothing, so there is nothing to protect. preventDefault on the handler covers the residual case a
layout might deliver, which is what the guard was really insuring against.

isTyping is deleted rather than left unused: a dead helper carrying a long comment about a decision that has
been reversed is worse than no helper, because the comment still reads as current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:37:20 +00:00
pastilhasandClaude Opus 5 6b07aafc67 spotlight opens on alt-space, and on nothing else
Third chord in a day, and each move was the owner naming a collision on the machine he actually uses:
ctrl-space is Brave's address bar, shift-space types a space, alt-space is the window menu — and he rebound
that rather than give up the chord.

Shift-space was the worst of the three and it is worth saying why, because it read as the safe choice.
It is a real key combination inside a text field: it types a space, and people hold shift through one more
often than they think while typing capitals. So it had to stand down whenever focus was in an input — which
is where focus usually is, the chat composer — and a launcher you have to click out of a field to reach is
not a launcher.

Alt-space means nothing in a text field, so the isTyping guard stops being the thing holding the feature up
and becomes belt-and-braces. Kept anyway: two lines against some layout delivering alt-space as a literal
character, which is cheap to prevent and expensive to discover.

`altKey` with `ev.code`, not `ev.key` — alt-space produces a different `key` on several layouts and nothing
at all on others, while the physical key is `Space` everywhere.

No second chord. Two triggers for one thing means two things to collide with, and this one has moved twice.

The TODO item's id still says ctrl-space. Ids are stable by design, so it stays; its body now records all
three moves and which guard was load-bearing for which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:31:44 +00:00
pastilhasandClaude Opus 5 f9dfd05676 header cluster and breadcrumb colour
Breadcrumb links rested at duck-forest and hovered to duck-teal; they now sit at the
hover colour, so the hover and its transition go with it. One shared component, so the
full browser and the dashboard widget both pick it up. The current folder stays
duck-dark semibold, keeping clickable and current distinct.

Header: the theme control leads the right cluster and names the theme it is wearing
rather than showing a palette glyph — with 227 of them, which one is on is worth reading.
It takes JobsIndicator's pill styling so the cluster stays one shape, and keeps its
colour on --header-fg rather than hardcoding white.

The jobs indicators and the rescan button are Super Admin only now. Cosmetic, and the
comment says so: /jobs stays reachable by URL and rescan is gated server-side, so
nothing here is load-bearing. Uses the same user?.role check UserMenu already makes in
this folder rather than adding a second convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:24:08 +00:00
pastilhasandClaude Opus 5 cbcd8d65df fix accent/secondary foreground derivation; 202 community themes
The nine remaining contrast failures all had the same cause, and it was a real bug in
map.ts rather than a limit of the source palettes: --accent and --secondary are both
s.high, but their foregrounds were taken from fgRaised, which is checked against
s.raised. That is precisely the near-miss the file already documents and fixes for
--muted-foreground, one token over. Deriving them against s.high instead rescues all
nine (every Solarized variant among them) and is provably a no-op for the official 22 —
each already clears 4.5:1 on s.high by 1.3x or more, and ensureContrast returns its
input untouched when it passes. Measured all 22 before the change; generate-themes.ts
--check reports 0 changes after it.

Also: GitLab-hosted repos are now discoverable (one theme lives there, and the raw-URL
and default-branch shapes differ from GitHub's), and sourceFor probes a directory named
after the theme's own slug, for the packs that nest one directory per theme. An exact
slug match is the only prefix tried on purpose — a pack whose variants have names of
their own has no single theme that is "the" one, so guessing would be wrong.

Every community theme is regenerated in this commit, not just the new ones. aura turned
up generated under an older pickAccent rule than its neighbours, because it was ported
before that rule changed — the same drift generate-themes.ts was written to prevent, and
the reason both halves are written from one source. All 202 now come from one mapping
version. Verified afterwards: 227 stylesheets, 227 imports, 227 registry entries, no
orphans in either direction.

Still out: 14 repos that 404 (deleted or renamed since the site indexed them),
grimdark-solarized (no source link at all), cobalt2 (its page links an awesome-list
README with no theme in it), and oasis (a six-variant pack with no variant named oasis —
porting it means inventing six slugs the site does not list, which is a naming decision).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 02:42:43 +00:00
pastilhasandClaude Sonnet 5 861fc4a58b add colors.toml and 0x-hex support, rescuing 44 more community themes
Poked at the 62 "no parseable source" skips from the first pass and found two things
the pipeline didn't handle: several community repos genuinely ship colors.toml too — some
in Omarchy 4's own semantic schema (best case, no derivation needed at all, same quality
as the official 22), others reusing the filename for an unrelated flat color0..color15
scheme — and a handful of alacritty.toml files use 0xrrggbb instead of #rrggbb.

palette.ts's loadPalette is now a thin wrapper over two exported pieces (parseTomlText,
paletteFromRecord) so the community pipeline can attempt the real schema first without
duplicating the reader; falls through to the color16 or ANSI-derived paths otherwise.
generate-themes.ts --check still reports 0 changes for the official set — the refactor
is behavior-preserving there, the lowercase-hex normalization it picked up along the way
touched 7 official theme files cosmetically (their source colors.toml has uppercase hex),
regenerated for real rather than left inconsistent with their own source.

147 -> 191 community themes ported. Of the original 62 unparseable repos, most turned out
to be simply gone (404 on GitHub — repos deleted or renamed since the site listed them);
the rest needed one of the two format additions above. 20 themes remain stuck: 16 dead
repos, 2 with no repo link at all, and the same handful of genuinely low-contrast source
palettes (mostly Solarized variants) where fixing contrast would mean changing the
author's actual colours rather than porting them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 01:48:21 +00:00
pastilhasandClaude Sonnet 5 a58f4d2b32 port 147 community Omarchy themes from omarchythemes.com
The official 22 themes come from basecamp/omarchy's own colors.toml, contrast-checked
by generate-themes.ts. A community theme has no such file — it ships per-app configs
(alacritty.toml, ghostty.conf) in the older 16-ANSI-slot style, so this adds a sibling
pipeline: from-ansi.ts derives the same 24-key Palette shape by role (accent, three
background depths, three foreground weights) from those 16 slots, then runs through the
same mapPalette + contrast checks the official themes use. Nothing about the official
generator changed — verified generate-themes.ts --check still reports 0 changes.

port-community-themes.ts discovers each theme's GitHub repo from its omarchythemes.com
page, fetches alacritty.toml (preferred) or ghostty.conf, and is idempotent via
community-manifest.json so a re-run only fetches what's missing. Community themes get
their own registry/index.css marker block, separate from the official GENERATED THEMES
block, so the two pipelines can never clobber each other.

121 of 212 attempted made it through cleanly; a second pass tightening accent selection
and nudging --input recovered 20 more borderline cases (147 total). The rest failed for
real reasons: 62 repos had no parseable colour source, 7 had an accent colour whose own
luminance can't clear 4.5:1 against either black or white text, a handful were narrowly
under threshold even after the rescue pass, and 2 pages had no repo link at all. Every
generated file's header states its source repo and whether it's a light or dark theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 01:37:05 +00:00
pastilhasandClaude Fable 5 a01ae73a1f todo: plugin permissions on by default for every role
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 00:22:25 +00:00
pastilhasandClaude Opus 5 78f9331b89 deploy report for 2026.08.21.04 — six hosts, one skipped on purpose
alpha, fofos, macbookpro, edge, production and deadpool all took 98177c3b, the
anthropic_accounts push and a restart. officer-dev is skipped because the agent
writing this runs on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 23:14:48 +00:00
pastilhasandClaude Opus 5 98177c3bc9 2026.08.21.04 — /login in the chat, account switching, and a spotlight that reaches the plugins
First release in three with a SCHEMA CHANGE. anthropic_accounts is new, and without the push the account
switcher's route fails on a missing relation the first time the usage panel is opened — so the plan names
db:push as a step rather than leaving it to be inferred from the diff.

alpha already runs this code, deployed by hand as a preview. The plan says its pull will be a no-op and to
run the rest anyway, so it does not read as a skipped host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:05:39 +00:00
pastilhasandClaude Opus 5 3cab16ca5b spotlight showed eight items, so every plugin was unreachable
MAX_RESULTS was 8. Core items alone fill eight slots, so on a populated server the empty-query list stopped
at Browser and none of the fourteen plugin tiles below it could be reached — unless you already knew the
name well enough to type it, which is not a thing a launcher gets to require. The owner caught it on alpha
with the dock and the launcher side by side; on this machine, which has few plugins, it looked complete.

The items were there the whole time. This was not a missing source — it was a silent truncation, with
nothing on screen saying "and 20 more", which is exactly how a cap reads as an absent feature.

There was never a layout reason for it either: the results container has scrolled since it was written.
Removed, and the active row now scrolls itself into view with `block: 'nearest'` so arrowing past the fold
works and arrowing through the visible middle does not jump the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:24:01 +00:00
pastilhasandClaude Opus 5 f023ab5da0 spotlight draws the dock's tiles, and lists the settings that actually exist
Two things made it read as a generic list rather than as this app's own, and neither was the item source —
that was already the dock, and plugins were already in it.

TILES. SpotlightItem dropped `tile`, so every row got a `${color}22` tinted square: a third visual language
belonging to neither of the dock's two. Which put a coloured pane behind Calendar, Contacts and every
plugin's logo — the exact thing the dock's own comment calls out as reading like a mistake. Rows now use the
dock's rule: a filled square of the item's colour with a white glyph, or no background at all when the
artwork brings its own frame. A settings row with no colour keeps a muted glyph instead of getting a swatch
invented for it.

SETTINGS. The hand-listed sections had drifted within two days of being written, and the file's own comment
predicted it ("worth revisiting if it drifts"). Nine rows against a settings area that has twenty-one:
Languages and Voice missing entirely, every AI and System section missing, `/settings/users` pointing at a
route that does not exist — it is `/settings/user-management/users` — and four different rows sharing the
User glyph, which is most of what made the panel look generic.

Now twenty rows, copied from the five screens that declare them, with each group naming its source file so
checking is one grep. The comment records that the drift HAPPENED rather than predicting it, and says
deriving them from the screens is the fix if it happens a third time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:17:58 +00:00
pastilhasandClaude Opus 5 b10a49c082 an account with no saved sign-in gets a chip you can read, not one you can use
Three chip states now, and the third is a different question rather than a broken version of the second:

  filled          the account you are
  plain           an account you can become — click switches
  dashed outline  an account you can only look at — click shows its last known usage

"Has my other allowance come back yet" is answerable from a snapshot, and the machinery for it was already
right: a reset time is an absolute instant, and currentLimit rolls a past window forward and zeroes it
instead of showing a stale percentage. Phase one was hiding a correct answer.

What you cannot do is act on it without a /login, so the chip says that before it is clicked (in its title
and in its border) and the panel says it again while you are reading one. Naming the fix rather than the
problem: "No saved sign-in — run /login to use this one".

Viewing is deliberately NOT a general tab selection. Only an account with no credential can be viewed,
because for any other one the useful gesture is switching to it and then reading live numbers — which keeps
phase one's invariant intact: if the panel shows a signed-in account, it is THE signed-in account. Switching
clears the view for the same reason, so the numbers and the chips can never disagree.

Replaces the count line, which was a fact about somewhere else. The staleness line that was already in this
file — "Not signed in · as of 20 minutes ago" — now finally has a case that reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:33:58 +00:00
pastilhasandClaude Opus 5 4b2cb3d266 the account chips show the whole address, because the local part is the shared half
Two chips both read `andre.padez`. They are andre.padez@gmail.com and andre.padez@pertento.ai — one
person's personal and work accounts, which is the entire case this switcher exists for.

The chip showed the local part on the reasoning that the domain is what accounts have in common. That is
true of accounts belonging to different people and exactly backwards here: the local part is the half they
share and the domain is the half that tells them apart. So the strip wraps instead of scrolling, and the
address is not truncated.

The "N other accounts not shown" line was also lying. It counted stored usage READINGS minus the one being
displayed, which since the switcher landed includes accounts whose chip is two lines below — it said "2
other accounts not shown" with both of them on screen. It now counts only accounts with a reading and no
saved credential: an old login you can look at and cannot return to, which is the thing actually worth
saying. On this machine that is one, jrg.conceicao@gmail.com.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:30:06 +00:00
pastilhasandClaude Opus 5 7d7a80782e switch Anthropic accounts from the usage pills
The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.

ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.

anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).

One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.

What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.

Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.

.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.

Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.

The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.

Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:24:28 +00:00
pastilhasandClaude Fable 5 bd9d26199f the ephemeral pane is open when it exists, not when an effect says so
Refresh on /files/...?view=x.m4a came back with the query string intact and no
viewer, and then double-clicking the file did nothing at all. Two symptoms, one
cause, and the second is what made it read as a click bug.

WorkspaceView gave the pane defaultSize={0} and opened it from an effect calling
panel.resize(), so open/closed was expressed twice — the markup said closed and
the effect was meant to correct it. A correction step can be missed, and this one
was: on a deep link isEphemeralOpen is true on the first render, but
workspace.isLoaded is false until /dashboards answers and the early return means
nothing is rendered, so the effect ran against a null ref and returned. Its deps
were [isEphemeralOpen, defaultBaseSize] — one already true, the other a constant
— so it never ran again. URL open, pane at zero width.

Then the inert double-click follows for free: ?view= already held that name, so
setSearchParams changed nothing, so there was no transition for the effect to
react to. The URL being already correct is exactly why the click had nothing to
say.

Now the pane is rendered conditionally with id + order — the same pair
WorkspaceRenderer already uses — and both panels take declarative sizes. There is
no moment where markup and intent disagree, and a deep link is right on first
paint rather than corrected after it. The try/catch that guarded resizing an
unlaid-out group goes with it; nothing calls resize now.

ephemeral-pane.test.tsx asserts the SIZE the group computes, not that the element
exists — a pane at zero width satisfies any is-it-in-the-document check, which is
the failure being guarded against. Five of its six tests fail against the old
implementation, including the load-order one. Full suite: 1316 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 21:11:15 +00:00
pastilhasandClaude Opus 5 58615ee5e7 /login can hand you the URL instead of opening it here
Opening the link opens it in THIS browser — already signed into whichever account you are trying to move
away from. Copying it is how you authorise as somebody else: another browser profile, another browser, or
your phone. This dialog exists for a machine with two Anthropic accounts on it, so that is the common case
rather than the awkward one.

Uses helpers/clipboard, which falls back to execCommand when navigator.clipboard is absent, and reports a
failure rather than showing "Copied" over an empty clipboard.

Corrected a claim in that helper's own comment while touching it: it said Officer is reached over the
tailnet on plain http, as though that were the whole story. The owner browses over https and did when it
was written. The http origins are the other ways in, and an install with no certificate has no other way
in at all — which is why the fallback stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:02:57 +00:00
pastilhasandClaude Opus 5 31cdb93691 usage shows the signed-in account only, and says which one it is
Phase one of answering a question the panel could not answer: whose numbers am I looking at.

This machine's owner has two Anthropic accounts, personal and work, and both had stored snapshots — so
both appeared as tabs. After signing in there was no way to tell from a glance which one was now being
spent. The panel was answering "here is usage for two accounts" while the question was "which am I on".

So the hook filters to the account marked `current`, and the absence of one now MEANS something: no current
row renders as "Not signed in" rather than as whichever tab happened to be selected first.

The email also gets its own line instead of sharing one with the plan. Joined as `Team · a@b.c` in a 320px
popover, the email is the half that truncates — and it is the half being read.

The other accounts are said out loud rather than silently dropped: "1 other account not shown". Knowing one
exists and is hidden deliberately is a different thing from wondering where it went.

Temporary and a one-line revert by construction: delete `onlyCurrent` in useClaudeUsage and return `all`.
The server still serves every account, the tab strip is still in the file, and nothing was deleted — the
stored snapshots are a real feature, they were simply drowning the question being asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:56:45 +00:00
pastilhasandClaude Opus 5 8917f36be5 /login works in the chat window
Officer authenticates claude with a Claude SUBSCRIPTION over OAuth, and that is a browser round trip: open
a URL, authorise, paste a code back. It was the one part of running an agent a browser-only user could not
do from the browser — the answer was "open a terminal panel and drive the CLI by hand", which works, and is
a strange thing to ask of somebody whose entire session is a web page.

It needs no pty. `claude auth login` behaves identically through plain pipes, verified by capturing its raw
bytes, so this is an ordinary subprocess with two reads and one write: spawn, read until the URL, wait for
the person, write the code to stdin.

The parsing is the only non-obvious part. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it
again as visible text, so `https://\S+` returns two URLs concatenated with no separator between them.
Stripping the escape wrappers first leaves exactly one. Written against captured bytes rather than against
what the terminal appeared to show.

Three decisions worth keeping:

NOT under /auth. totality.ts exempts that prefix from the permission gate entirely, and correctly — it is
where a caller goes to BECOME authenticated. Mounted there, an unauthenticated POST could have started a
login as the owner. Verified after moving it: unauthenticated POST /api/claude-auth is 401.

On the `chat` grant rather than its own. The pairing is exact: an account that may use the agent must be
able to authenticate the agent, and an account that may not has nothing to authenticate. `confined` then
carries the rest for free — authorize.ts drops a confined grant for an account with no osUser, which is
precisely the account that cannot hold credentials of its own.

Nothing Anthropic-shaped is inherited by the child. For the owner those variables point at the local proxy,
whose whole job is swapping a shared secret for the owner's bearer token — handing them to a login is how
you get a success that authenticates nothing. A member's login runs through setpriv --reset-env as their own
Linux user, into their own home, because their turns bill their own subscription and that only holds if the
credential is theirs.

Pending logins are in memory on purpose: one is worthless once the process holding its PKCE verifier dies,
so persisting it would store a row that can only ever be stale. Ten-minute reaper, one per account, and the
dialog abandons its own on close so a dismissed dialog does not leave claude parked on a read of stdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:47:10 +00:00
pastilhasandClaude Opus 5 530e5880e1 todo: what tonight's deploy work left behind, and two items it closed
Closed:

- fleet/an-orchestrator-that-acts-on-every-server-at — built, and as none of the three shapes it
  proposed. Not a script, not a screen, not a chat capability: an agent handed a written procedure and a
  server list it has to read rather than one baked into it. All three verified facts in the item earned
  their place; two things it did not anticipate are now new items.
- fleet/a-way-to-mark-a-commit-this-one-ships-so-a — built as a TAG, which the item explicitly ruled out.
  The owner reversed it, and the argument that changed his mind is recorded: a codeword can be written by
  accident, because every document about deploying contains it, so the trigger needs rules about where in
  a subject line it sits and a rule like that is one somebody eventually gets wrong.

Opened, all from tonight and all with the evidence attached:

- a deploy verifies pm2 and not that the app serves anything — the commander raised this against itself
- the commander cannot finish its own host, so one machine is always a build behind
- the watcher loop is rewritten by an agent every arming, so its correctness is re-rolled each time
- plugins:update restarts every sidecar, so a report naming only officer understates what moved
- an agent spawned by a background job is invisible in the UI while being perfectly readable
- AskUserQuestion can fail to parse its own call
- a commander opened from /chat loses its commander controls, because isCommander comes from the screen
  rather than the session

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:27:00 +00:00
pastilhasandClaude Opus 5 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>
2026-08-21 20:23:45 +00:00
pastilhas 9ce81547c4 2026.08.21.03 deployed to six hosts — the watcher fired, the claim held 2026-08-21 20:14:36 +00:00
pastilhasandClaude Opus 5 3343b21a17 2026.08.21.03 — a drill, to watch the watcher fire on its own
No code change. The point is to see a tag picked up by a loop nobody is looking at, and to exercise the
claim rule that landed after the last deploy.

Not literally an empty commit, and it cannot be: the commander stops when the tag and package.json
disagree, and stops again when the plan file is missing. So the smallest honest release is a version and a
plan, which is what this is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:06:28 +00:00
pastilhasandClaude Opus 5 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>
2026-08-21 20:02:24 +00:00
pastilhasandClaude Opus 5 8be09a012b 2026.08.21.02 deployed to six hosts, and officer-dev is still waiting
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:01:25 +00:00
pastilhasandClaude Opus 5 55ecbf4fb8 the handoff radio was still rendered nowhere, one layer above where it was fixed
"Exit and hand back to the agent, then re-arm" is the only fire behaviour a commander wants, and it is the
dialog's own default inside one. It was missing from the dialog in Command HQ.

The previous fix wired the consumer: isCommander={manager.context === 'command-hq'}, with a comment about
how the prop had never been passed by anybody. The producer was still not feeding it. EmbeddableChat builds
its manager from its OWN props — useEmbeddableChat(params) — and `context` there is a different value from
the `context` handed to useChat. Command HQ set the hook's and not the component's, so manager.context was
undefined, and the radio stayed hidden in the one place it exists for.

Two hooks named alike sitting on either side of that boundary is what hid it twice: hooks/useChat.ts and
EmbeddableChat/useEmbeddableChat.ts, one taking a `context` option, the other a `context` prop, neither
reading the other's.

Found by the owner trying to arm a deploy commander and finding only "stop when it fires", a one-shot,
where a watcher that re-arms was the whole point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:42:04 +00:00
pastilhasandClaude Opus 5 926ce86b83 2026.08.21.02 — command hq's transcripts render, and live separates commanders
Frontend and one chat route. No schema change and no dependency change, verified against the previous
tag rather than assumed: nothing in this range touches src/schema.ts, package.json dependencies or
bun.lock. The plan says so explicitly, because a host that runs db:push here has run something the
release did not ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:33:15 +00:00
pastilhasandClaude Opus 5 e1ad680475 a commander opens in /chat, where you were already reading
Clicking one in the Live list navigated to /command-hq — out of the screen, into a different route with a
different layout, having lost your place in the list you were scanning.

I argued for that in the commit before this one: /chat renders the transcript without the category, cost,
watcher state or the disarm control. It loses to a simpler argument. A commander IS a chat — same harness,
same transcript, resumable by the same route — and being thrown out of the page you are reading costs more
than metadata you did not ask for. Command HQ is where you go to MANAGE a commander, which is a separate
errand from reading what it just said.

Verified rather than assumed, because a commander's cwd is not one of /chat's directory groups: the
default-group load misses, and GET /chat/sessions/:id falls through to the by-id scan, which returns all
six messages and the real cwd with it. So the detail panel resumes it with no cwd hint at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:30:06 +00:00
pastilhasandClaude Opus 5 ebfa09e067 live splits into Agents and Commanders, and the payload says which is which
A commander is a chat by every mechanism it uses, so it turned up in Live beside the conversations
somebody is actually having. With one deploy commander that is untidy. With a watcher armed per repo it
is a list where the thing you are looking for is outnumbered.

Two tabs inside Live, counts always shown. That last part differs from the badge on the Live tab above it,
which hides at zero because a standing `0` there says nothing. These two say a lot at zero:
`Commanders (0)` is how you find out the watcher is not running, which is usually why you looked.

The flag is derived server-side from the cwd, not from a `command_hq_sessions` lookup — every commander
shares one directory, so the directory IS the answer, it costs no query, and it is already right during a
commander's first turn, before any row exists.

It has to be in the payload rather than computed in the panel, and that is the point of the change: the
iPhone app swipes between LIVE chats and has no room for tabs. Filtering there needs the server to name
them. A deploy commander appearing in that swipe order is precisely what must not happen.

A commander row also now opens Command HQ instead of /chat. Same transcript either way, but /chat renders
it with nothing that makes it a commander legible — no category, no cost, no watcher state, and no way to
disarm it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:25:17 +00:00
pastilhasandClaude Opus 5 6dae3e7a14 a commander's transcript was fetched, returned, and then ignored
The pane still showed "Send a message to start" over a bar reading 1 turn · $0.28. The id fix was
necessary and was not the cause.

useChat seeds its message list with useState(initialMessages ?? []) — read on the first render and never
again. ExistingCommander fetched the transcript AND called useChat in one component body, so the hook's
first render happened with resume still undefined and seeded itself empty. The messages landed a moment
later and the hook had no reason to look. The early `Loading…` return does not help: hooks run above it.

ChatPanelWrapper has always avoided this, and the line that does it is easy to read as a loading
nicety — `if (selectedSessionId && resuming) return null` sits ABOVE the component that owns the hook, so
the hook's first render is its only render, and it has real data. Split the same way here: a fetcher, and
a chat mounted once there is a transcript to mount it with.

Verified server-side while looking for this: loadClaudeSessionById returns the six messages for
172cdd8c, ending on the commander's answer. The data was always there and always reachable. Only the
pane could not see it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:22:06 +00:00
pastilhasandClaude Opus 5 2379260ffe the commander pane threw its own conversation away at the end of the first turn
The messages were on screen, streaming, correct. Then the turn ended and the pane went empty under a
metadata bar still reading 1 turn · $0.66.

Nothing was lost — the pane discarded it. Once the transcript id was known, NewCommander navigated onto
it, and CommandHqDetail answered that URL with a DIFFERENT component: ExistingCommander, keyed on the id.
So React unmounted the live chat, closed its socket, and re-read the transcript from the API — a file the
harness had only just finished writing. What survived was whatever that fetch returned, which was nothing.

A permalink is for a refresh, not an instruction to reload. /chat has always understood that: useChat
replaces the URL and remounts nothing. This pane replaced the URL and remounted everything.

So the pane now keeps the session it started. CommandHqDetail remembers which id came from its own
composer and keeps that component mounted while the URL names it, swapping to the resuming component only
for a commander opened from the list. Clearing the memory on a real navigation means coming back later
still resumes from the transcript rather than remounting an empty live chat.

Worse than the empty pane, and the reason this is not cosmetic: the teardown fired at session:claude for
any chat where the harness names its transcript early — the START of a turn. A commander's turns run for
tens of minutes. The pane would have dropped the stream and shown a reader nothing at all while a deploy
rolled across seven servers.

claudeSessionId is now also set at session:claude, not only at result, matching the ref beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:10:54 +00:00
pastilhasandClaude Opus 5 21b893473d a new commander showed "Send a message to start" after answering correctly
The first deploy commander ran, cost $0.66, and reported exactly the right thing — and the pane showed
an empty state, because the page was fetching an id nothing knows.

Two ids for one commander. `session:init` sets `sessionId` to OFFICER's key and arrives first;
`session:claude` carries Claude's transcript id and arrives later. `fileAsCommander` files the row
under the transcript id, and `command_hq_sessions` is keyed on it. But NewCommander navigated on
`chat.sessionId` — the first one it could see — and then set `filed`, which blocked the correction when
the real id turned up. So the URL held officer's key, /api/chat/sessions/<that> 404'd twice, and the
transcript was unreachable from the UI while sitting perfectly readable on disk.

The transcript id was only ever in a REF, deliberately — the reconnect handshake reads it synchronously
— so writing it re-rendered nothing and no caller could react to it. It is now state as well, returned
alongside sessionId, with the ref kept for the handshake that needs it.

setCategory had the same fault and would have silently updated no row: it was keyed on officer's key
against a table keyed on the transcript id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:59:03 +00:00
pastilhasandClaude Opus 5 af78f0ed51 sessions carry isCommander, so the phone can filter what tabs would hide
A commander IS a Claude session and this list reads transcripts from disk, so nothing distinguished the
two: commanders appeared in the chat list looking like conversations somebody started and forgot.

The flag is in the PAYLOAD rather than in the web UI, and that is the owner's constraint rather than an
implementation preference. The iPhone chat app swipes left and right between live chats and has no tabs
to hide behind, so it has to filter commanders out of that gesture itself. A flag only the web knew
about would fix one surface and leave the other wrong — which is the shape of half the bugs found
tonight.

Derived from the CWD, not from a join against command_hq_sessions. Every commander a user has shares one
directory — getCommandersDir(email), never per-commander and never client-chosen — and this list already
reads each session's cwd. So it costs one string comparison, adds no query, and still works for a
commander whose database row was lost. Compared after resolve(), because the cwd comes from a transcript
rather than from code and records whatever directory the process was actually in.

Verified against this host: 8 sessions, 0 flagged, and that is right — the commanders directory exists,
no transcript has that cwd, and Command HQ shows 0 commanders.

Still to do: the two live tabs in the web list, Agents (n) and Commanders (n), with live counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:41:43 +00:00
pastilhasandClaude Opus 5 7a46612f41 the handoff watcher was built and never rendered
BackgroundJobDialog offers three fire behaviours and gates the third, `handoff` — exit, act, re-arm — on
an `isCommander` prop. Nothing ever passed it. So it defaulted to false everywhere and the option
appeared NOWHERE, including inside a Command HQ commander, where the dialog's own code makes it the
default.

Everything else was there: the fire rule, `handoffContract`, `armWatcher` reaching the server, and a note
recording that it had run for hours self-re-arming without failure. Only the radio button was missing,
which made a working mechanism look unbuilt — and it is the one a deploy watcher needs, since
`keep-going` never hands back to an agent at all.

Found while telling the owner how to start the deploy commander: the dialog in a commander showed two
options where I had said there would be three.

The fix is two lines and no new concept. `useEmbeddableChat` already receives `context` and passed it
into `useChat`; it now also returns it, and InputArea gives the dialog `isCommander={context ===
'command-hq'}`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:30:14 +00:00
pastilhasandClaude Opus 5 240d8adbd3 a release is a tag, and the lock that stops a second deploy is local
Three changes, all from the owner's call.

The trigger is a tag, deploy/<version>, and nothing else. The code word it replaces could be written by
accident — every document about deploying contains it, this repository included, and so does every
conversation planning one. Defending that needs a rule about where in the subject line it sits, and a
rule like that is one somebody eventually gets wrong. A tag cannot be typed into prose, it names its own
version, and git ls-remote answers "is there one" without fetching.

The record of a deploy is LOCAL — data/deploys/<version>.done — not the pushed report. This is the part
that matters: making the report the marker means a failed push leaves the release looking undeployed and
the next tick rolls it out again. Pushes here fail for reasons unrelated to the rollout — gitea's public
SSH is rate-limited because every connection arrives from one proxy container IP that bots keep
penalised, which cost several retries tonight. So the lock cannot fail for a network reason, and the
report is an artefact: valuable, not load-bearing. Under data/ so a pull cannot clobber it.

And the commander skips the host it runs on ENTIRELY, naming it in the report. Restarting only officer
and leaving its own sidecar behind would work and is worse — one host in a state nobody chose, a step
behind, found later. The owner restarts it deliberately as the last step.

Tonight's deploy is backfilled: the lock file exists and deploy/2026.08.21.01 is tagged at bd41dff5, so
the mechanism has a precedent rather than starting from nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:21:09 +00:00
pastilhasandClaude Opus 5 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
bd41dff5.

Its login is `br` and always will be. That makes the SSH user a per-host fact rather than a constant,
so the table carries it and the deploy prompt reads it. Worth stating plainly because the failure is
misleading: an orchestrator assuming `pastilhas` gets "Permission denied (publickey)", which reads as a
broken key rather than a wrong username.

Also folded in what tonight established. Three hosts answer to names that disagree with
`tailscale status` — edge-bridge and gitea-bridge are the old ones — so the table says which to use.
`edge-bridge` was listed as "not SSH-reachable" and that was wrong: its SSH was down at the moment I
checked, not absent. The pm2/nvm PATH trap is written in, including that `-lc` is not enough and cost
two wrong conclusions, and that macbookpro must not be helped with an exported PATH because doing so
breaks pm2. And a dirty tree means TRACKED changes; plain --porcelain skipped a host over a stray file.

officer.rezio.dev is recorded under "known wrong, waiting on someone else": it points at gateway rather
than deadpool, which is worse than unavailable — it will serve the wrong box the moment gateway answers
on 443.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:58:46 +00:00
pastilhasandClaude Opus 5 bd41dff59f 2026.08.21.01 deployed — 6/6 hosts
Every platform on eee73e77, schema pushed, processes online, domains answering. Rolled out by hand
one host at a time, since nothing wakes the commander yet.

Three things the report records rather than glosses. officer-claude-code was NOT restarted on
officer-dev — that sidecar holds the session running the deploy, so restarting it would have ended the
rollout mid-flight; every other host got it and it is outstanding here. fofos failed to sync
capabilities with a "dubious ownership" refusal whose stated cause is wrong (ownership is correct), so
it is left unexplained rather than silenced with a safe.directory entry. And macbookpro has no plugins
installed at all, which is worth knowing before somebody expects one there.

Two lessons for the prompt. The dirty-tree check counted untracked files and skipped fofos over one
stray script a fast-forward could never touch — it wants --untracked-files=no. And macbookpro needs
$SHELL -ic and no help: exporting a PATH to reach bun broke pm2, which lives only on the interactive
shell's path.

The pre-flight earned its place: username became NOT NULL with a case-insensitive unique index, and
checking all six first — 12 users, zero nulls, zero collisions — is why db:push applied everywhere
instead of failing on the third host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:37:54 +00:00
pastilhasandClaude Opus 5 eee73e777c DEPLOY_NOW 2026.08.21.01 — the night's work, to all six
First release under yyyy.mm.dd.NN, and the first deploy of the plugin extractions, the regenerated
themes, Spotlight, and the chat fixes.

The plan is deploy/2026.08.21.01.md. Two steps in it are required rather than optional and both would
be easy to skip: bun install, because bun.lock changed and sharp left the platform's dependencies for
atelier's; and db:push, because users.username becomes NOT NULL with a case-insensitive unique index.

That second one can refuse — on a null username, or two names differing only in case. Checked on all
six hosts before writing this rather than discovering it mid-rollout: 12 users, zero nulls, zero
collisions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:29:02 +00:00
pastilhasandClaude Opus 5 2261d16a8a bun run plugins:update, and the deploy prompt stops carrying its own server list
Updating plugins was only possible through POST /api/plugins/update-all, which is authenticated — so a
deploy had to mint an API key to update the machine it was already logged into. A credential created,
used and hopefully deleted, on every host, for a local operation.

Same work through the same functions with the HTTP layer removed, not a second implementation beside
it. Sequential like the route (each update pushes a schema, republishes assets, rewrites the ecosystem
file and rebuilds the frontend — two at once is a race over one file each), one plugin's failure does
not stop the rest, and it exits non-zero if any failed so a deploy can stop rather than report a
success it did not have. Verified: 3/3 in 18s.

Its header says the one thing that is not obvious — it runs OUTSIDE the server process, so files,
schema, assets and the SPA bundle are current when it finishes but the route table is not, until
something restarts officer. A deploy restarts anyway, which is exactly why that would otherwise be
found out rather than known.

The prompt had a table of six hosts under a line saying docs/servers.md is authoritative. Both cannot
be true, and the copy is the one people trust while it rots. Removed, with the reason written in its
place so it does not come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:24:47 +00:00
pastilhasandClaude Opus 5 07f854bf11 a prompt for the deploy commander, and the shape of a release
The agent watches for a commit whose subject BEGINS with the code word, rolls it out to the six
platforms in docs/servers.md, and reports back. It never decides what a deploy needs — the plan is
written for it in deploy/<version>.md, and an agent improvising a db:push is how a database gets a
schema nobody was ready for.

Four traps written in rather than left to be rediscovered, all verified tonight: take the checkout path
from PM2's pm_cwd because alpha has a stale second checkout that pulls successfully and changes
nothing; reach pm2 with `$SHELL -ic` because it lives under nvm in .zshrc and a login shell does not
read that, which is how I concluded twice that a machine had no pm2 while it was plainly running;
remotes differ per host so pull in place rather than assuming a URL; and macbookpro is a laptop that
may be asleep, which is a skip and not a failure.

Idempotence is the report's own existence — deploy/<version>.report.md on origin means deployed. A
watcher that restarts must not roll the same release out twice, and nothing else was standing between
it and doing so. The report's subject deliberately omits the code word, or a report would trigger a
deploy and loop.

Two gaps stated rather than papered over: there is no CLI for updating plugins, only an authenticated
route, so the plan has to say how or the report has to say it was skipped; and nothing wakes the agent
yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:20:46 +00:00
pastilhasandClaude Fable 5 44f6c6d6ac todo: group the url and query-string items into one theme
files-app becomes url-state: the file browser is where the work happened, but where
selection lives and who clears a query string are framework questions. The panel
framework's version of the same question moves in beside them, extracted from the
deep-read item, which keeps it as one of its three questions.

Item ids changed with the theme. Done now deliberately — no board reads them yet, and
this is the last cheap moment to move one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:40:39 +00:00
pastilhasandClaude Fable 5 ef2c9c51f7 todo: regenerate the index
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:47:52 +00:00
pastilhasandClaude Opus 5 8d476129ea an answer that goes nowhere now says so, and one that lands says that too
Second half of the AskUserQuestion report: "when I answer, the agent does not react — I see the bubble
for a long time and end up sending a message anyway", intermittently and not reproducible.

Two different causes, both invisible from the outside.

A DROPPED answer was silent at every step. user-instance discarded the boolean, sendFire does nothing
when no sidecar is connected, and the lookup returns undefined for both "no such session" and "not
yours". So a lost answer and a slow agent looked identical: a card that does not change. It now warns
with the reason, and where a session exists it emits an error into the durable log — the same channel a
successful answer uses, so a second pane sees it too, rather than confirming to the browser that
clicked.

A LANDED answer had the opposite problem. The card goes read-only the moment you pick, but `sent` is
local and optimistic while `message.output` only arrives once the harness has produced something. In
between there is a real gap, and nothing filled it — so an answered card looked exactly like one that
had timed out. It now says "Answer sent — waiting for the agent", which makes the two states different
without pretending to know when the agent will speak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:31:25 +00:00
pastilhasandClaude Opus 5 1a215dcfee a turn waiting on a person is not a stalled turn
Reported as: leave an AskUserQuestion unanswered for a while and the chat becomes unusable — refresh,
send another message, the agent is stuck.

armStall knew about compaction and about background tasks but not about a pending gate. Parking on a
question or a plan means emitting nothing until somebody answers; that is what parking IS. So the
watchdog could not tell it from a wedged turn, and at 10 minutes against gates that wait 20, it always
won:

  10m  the turn is declared dead, isGenerating goes false, an error is emitted, the session is torn down
  20m  the gate's own timeout would have resolved it truthfully — never reached

That left a ten-minute window to answer. Miss it and the card is still on screen with nothing behind
it, and clicking an answer does nothing at all: an answer's only receipt is the tool:result the gate
emits when it unblocks, and the gate is gone. From the outside that is precisely "unusable, refresh,
send another message".

A session with an outstanding question or plan is now left alone by the watchdog. Not unbounded — each
gate holds its own 20-minute timer and resolves with a sentence written for the agent — and the timer
is re-armed on both resolve paths, the answer and the timeout, so a turn that genuinely wedges AFTER a
question is still caught. armStall skips itself while any gate remains, so answering one of two
questions keeps the reprieve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:22:04 +00:00
pastilhasandClaude Opus 5 ff7358188d sub-agents: a spawn cannot take the last of a window
The failure this prevents is specific — a sub-agent burns the end of a limit window and the session the
owner is actually sitting in dies mid-turn, because the credits went somewhere nobody was watching. So
it is a condition on spawning rather than a refinement: an endpoint that can start unattended work
without checking is wrong from its first call, and the symptom lands on someone else's conversation.

Wired into POST /command-hq/commanders/new, which — this is the finding — already IS the spawn
mechanism the sub-agents item was asking for. It starts a turn with ws: null, waits only for
session:claude, and returns the transcript id, with command_hq_sessions naming, listing and costing it.
So "commander or something lighter" answers itself: a commander costs nothing extra and a lighter thing
would need its own registry to be visible at all. What is still missing is only the agent-callable
surface.

Refuses rather than queues or downgrades. Queueing means holding work against a window Officer does not
control, and the reset it would wait for is a claim about a shared pool — every claude on the machine
spends these, including terminals and pipelines. A refusal naming the window, the percentage and the
reset is something a caller can act on now.

The floor is 15% and the reasoning is in the file: the interactive failure is not "cannot start a chat",
it is "a turn already in flight is cut off", and a turn costs a fraction of a window.

Fails OPEN, deliberately. The numbers come from an external endpoint with a credential Officer does not
own, so a blip or a signed-out CLI must not stop every spawn on the machine with an error about usage
that has nothing to do with usage. The problem is reported so a caller can say "started, but unchecked".

Seven tests on the pure rule — tightest window decides rather than the average, no limits allowed rather
than blocked, exactly-at-the-ceiling refuses. Verified against the live account, where it correctly
refuses right now at Session 90%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:42:27 +00:00
pastilhasandClaude Fable 5 4c26e13535 todo: OPM — officer's processes on a manager of their own
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 06:05:57 +00:00
pastilhasandClaude Fable 5 645c8e36eb todo: urgent — per-feature server routing, due tomorrow for reclip
Adds priority/due to the item format and an Urgent section to the generated index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 06:03:51 +00:00
pastilhasandClaude Fable 5 47f3fbaf76 todo: centralise custom components, with per-theme overrides
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:58:42 +00:00
pastilhasandClaude Fable 5 a34ef422f4 todo: turn timer in chat, and a sub-agent spawn endpoint bounded by usage limits
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:44:47 +00:00
pastilhasandClaude Fable 5 6071ded175 todo: one file per item, grouped by theme, with a generated index
TODO.md had reached 1050 lines and 72 open items across 28 sections, read mostly
on a phone. It is now a directory: todo/<theme>/<item>.md, one card per file, with
frontmatter carrying id, status, created date and order, and the prose body copied
across verbatim. TODO.md becomes a generated index (bun run todo:index).

Dates: where an item's prose stated one it is used; otherwise its old section's;
otherwise 2026-08-20, floored at the day before the split rather than invented.
date_source records which.

Shaped for a two-way Kanban board, filed as todo/tooling — a card is a file, id is
the identity, frontmatter is the machine's half and the body is the owner's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:42:39 +00:00
pastilhasandClaude Fable 5 8fa560df1b todo: backend turn delivery exists — correct three stale entries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:25:57 +00:00
pastilhasandClaude Fable 5 446f3fa64d todo: one audio inbox and a dispatcher agent that routes it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:18:44 +00:00
pastilhasandClaude Fable 5 31e858bcb7 docs: a running list of servers, and the fleet updater it feeds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:17:14 +00:00
pastilhasandClaude Fable 5 030db140c4 todo: split the watcher — a script transcribes, an agent reads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:10:52 +00:00
pastilhasandClaude Fable 5 589d83f191 todo: a standing agent job may already be the watcher
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:09:16 +00:00
pastilhasandClaude Fable 5 1f234c8282 todo: directory watchers with attached actions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:06:57 +00:00
pastilhasandClaude Fable 5 2e76cabf4f todo: prompt library, sudo terminal modal, iphone action button
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 04:51:18 +00:00
pastilhasandClaude Opus 5 03abdeb246 an Update button, so a server is not a terminal away from current
Maintenance had "rebuild the frontend" and "restart the server" — one compiles what is on disk, the
other reloads it, and neither CHANGES what is on disk. Doing that meant ssh, on every server, every
time.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 04:22:30 +00:00
pastilhasandClaude Opus 5 4e2db3dc2f themes: generate all 22 from Omarchy, with contrast as a test rather than a taste
The eleven ported themes declared 34 of an 84-token contract. The missing fifty were every surface
(--panel-bg, --card-bg, --glass-bg), every state colour (--success, --warning, --busy, --info), the
header, the scrollbars and the grid — so they fell back to whatever the base stylesheet or the
previously applied theme had left behind. They also carried thirteen tokens nothing in this codebase
reads (--chart-1..5, --sidebar-*), which is the tell: they came from a shadcn template, not from the
contract in the README beside them.

So "highlights mask the content" and "text fades into the background" were not taste. They were
absence, and no amount of re-picking colours would have fixed them.

Generated now, from Omarchy 4's colors.toml, which is genuinely semantic — three background depths,
four foreground weights, accent, selection, muted, mode — and consistent: all 24 keys present in all 22
themes, checked. One mapping in scripts/themes/map.ts means one place to be wrong, one place to fix,
and an Omarchy update becomes a re-run.

Every foreground is driven through ensureContrast against the surface it actually sits on, and the
generator then re-checks sixteen pairs per theme and EXITS NONZERO on a failure. It found four real
bugs in my own mapping, which is the argument for having written it first:

  - `on(fill)` picked the better of two palette extremes without checking either passed; rose-pine's
    tones are all mid and came out between 2.2:1 and 4.2:1
  - ensureContrast chose black or white by a luminance>0.5 test rather than by measuring. rose-pine's
    rose sits at 0.25, so it drove toward white and capped at 3.5:1 where black reaches 6:1 easily
  - --muted-foreground was derived against the raised surface but renders on the high one; everforest
    landed at 4.44:1 and miasma at 4.00 — near-misses that look fine in a screenshot
  - borders were held to 3:1, which would ring every panel in a hard line. A panel edge is decoration;
    what must be discernible is a form field's boundary, so --input is checked and --border is not

35 failures at first run, 0 now.

The registry and the stylesheet imports are written from the same list, because the old port maintained
them by hand and drifted — catppuccin-mocha sat in THEMES after its stylesheet was gone. That file is
now deleted; Omarchy renamed it to catppuccin.

Eleven new themes arrive with this: ethereal, flexoki-light, hackerman, last-horizon, lumon, lupine,
miasma, retro-82, solitude, vantablack, white. Duck and Plain are Officer's own and are never generated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 04:18:58 +00:00
pastilhasandClaude Opus 5 a2cca526f9 spotlight opens on shift-space, and stands down inside a text field
ctrl-space focuses the address bar in Brave, so it never reached the app.

shift-space is a real combination inside a text field — it types a space, and people hold shift through
one more often than they think while typing capitals. So the trigger is ignored when focus is in an
input, a textarea or anything contenteditable, and the space is typed as normal.

The cost is worth stating rather than discovering: focus usually IS in the chat composer, so opening
Spotlight from there means clicking out first. If that turns out to be the common case, a double-tap of
shift is the escape hatch — it conflicts with nothing anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:51:57 +00:00
pastilhasandClaude Opus 5 81f32a1faa spotlight: ctrl-space, type a few letters, go
The dock is full and cannot grow. Once navigation lives here it is free to be a short list of what you
actually use rather than a complete one — which is the other half of this and is not done yet.

It INDEXES THE DOCK rather than keeping a list of its own. The dock is already the composed,
permission-filtered answer to "what can this account open here": the shell's screens, plus whatever
installed plugins contribute, minus anything canVisit refuses. A second list would be a second answer
to one question, and it would be wrong the day somebody installs a plugin. So a new plugin appears in
Spotlight for the reason it appears in the dock — because it is installed — and nothing here is edited.

Settings sections are added, because they are what people hunt for and the dock deliberately does not
carry them. Those ARE hand-listed, which is a real cost written down in the file: a section added to
IntegrationsSettings will not appear until someone adds it here. The alternative couples five settings
screens to the shell to save five lines.

Matching is subsequence, not substring, and lives in its own file so it can be tested with strings:
"chq" finds Command HQ, "apik" finds API keys, "fi" gives Files rather than Profile. The weights encode
what "the right result" means — exact label, then prefix, then word boundaries, then adjacency, with
shortness breaking ties so "Chat" beats "Chat history". Labels are worth about three times hints, and
paths almost nothing, so a path fragment finds an item without ever outranking a real label. Nine tests
cover the ORDER rather than the matching, because returning the right set is easy and returning it with
the right thing first is the product.

ctrl-space, not alt-space: the more macOS-shaped chord is already load-bearing for the owner. Not
cmd-k either — that belongs to the browser on some platforms and to text fields on others.

Mounted beside the header and the dock rather than inside the content region, so it still gets you off
a screen that is maximised, erroring or mid-navigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:48:46 +00:00
pastilhasandClaude Opus 5 ee7bc4920c three new todos, seven closed, one real bug fixed on the way
Added: a Spotlight-style launcher on ctrl-space with the dock pruned behind it, plus dock effects and
auto-hide; a repeating-prompt option on the background-job starter, which is the Nudger in the place
it belongs — attached to a job already running rather than a schedule pointing at a session that may
not exist, and inheriting its three refusals; and a deep read of the Workspace/Panels framework, low
priority and explicitly not pain-driven, with the existing panel items folded into it because they are
symptoms of the same unexamined design.

Closed after checking rather than assuming — five of the seven were already done and the entries
outlived the work. The whole offscale rename debt is finished: panels read "Offscale", the picker
heading is right, console session keys were renamed on 2026-08-16, and there are zero stale
/headscale paths or PLUGIN.md self-references. The one remaining mention is a form placeholder,
https://headscale.example.com, which is correct — that IS a Headscale URL. offscale declares ssh as an
osDependency. The manifest module-cache is fixed by importFresh.

example/manifest.ts named web/Router.tsx as the frontend convention; discovery wants panels.ts and
layout.ts, by those exact names, or it refuses the plugin.

One that was NOT already done, and I nearly closed it by mistake: reindex-music.ts moved into the
music plugin but still read process.env.DATA_PATH, which stopped existing on 2026-08-13. Run from the
repo it looked in platform/data/music/.server — one level too deep — and reported the sidecar as not
running. It now imports DATA_PATH from the platform, which resolves to /home/pastilhas/officer-dev/data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:38:37 +00:00
pastilhasandClaude Opus 5 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>
2026-08-21 03:19:17 +00:00
pastilhasandClaude Opus 5 4984fdc3a2 todo: let people see and switch off the affordances reaching their agents
They are global — affordanceBlock() reads every file and is called with no user context, so every
account gets every one in every conversation.

Fine while they are uncontroversial, and not fine straight after. "Wait for the question" was written
today to one owner's taste, is probably what most people want, and nobody asked anyone. An affordance
nobody can see and nobody can decline is a preference imposed as a fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:07:33 +00:00
pastilhasandClaude Opus 5 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>
2026-08-21 02:44:25 +00:00
pastilhasandClaude Opus 5 d043e6650a name a published extension for itself, not "extension.zip"
extension.zip says nothing once it is in a Downloads folder — not what it is, not which server it came
from, and not which of two it is. The name comes from the extension's own manifest.json, which already
carries what a browser will show: "Officer Browser Relay" becomes officer-browser-relay.zip.

Generic, since extensions are meant to be a marketplace of them: any plugin shipping extension/ gets a
file named after its own manifest, falling back to <appName>-extension.zip when that cannot be read —
still better than extension.zip, and it never throws, because a broken extension manifest should fail
the install for its own reason rather than because a file could not be named.

Uninstall removes both names. An install from earlier today published extension.zip, and leaving that
behind would serve a stale extension for a plugin that is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:52:24 +00:00
pastilhasandClaude Opus 5 cfb2f98ccb plugins can ship a browser extension, published unauthenticated
The browser relay is the first extension, not the last, so `extension/` is now a directory any plugin
may ship — like `assets/` — and the installer zips it to public/plugins/<name>/extension.zip, served
by the existing /plugins/* wildcard with no auth.

Unauthenticated deliberately. An `<a download>` is a plain navigation with no Bearer header, so an
authenticated URL makes Chrome say "Try to sign in to the site. Then, download again" — advice that
cannot work, because signing in is not what is missing. Nothing in an extension is secret; the pairing
token is, it is entered afterwards, and it is not in the archive.

Built at install rather than committed. The file it replaces was hand-built and had drifted 58 lines
behind its source, serving an extension missing the multi-session recovery path.

zip.ts writes stored entries with fixed timestamps — no host `zip` binary, and identical bytes for an
unchanged directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:36:43 +00:00
pastilhasandClaude Opus 5 45d4823238 browser pairing and its extension zip leave the platform
The last two pieces the extraction missed. IntegrationsSettings/BrowserRelay.tsx drove the plugin's
pairing from a platform screen, calling /browser/relay-token and working only because the plugin
happens to serve that path — the same coupling removed everywhere else this morning.

public/browser-relay-extension.zip was a hand-built snapshot that had drifted 58 lines behind the
extension it claimed to be: built in August from something predating a February commit, never rebuilt,
and missing the multi-session recovery path. The plugin builds the archive from its own directory per
request now, so there is nothing left to fall behind.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:05:11 +00:00
pastilhasandClaude Opus 5 581ec28fc5 record the two pieces of plugin infrastructure the desktop extraction needs
Websockets: a plugin's socket has no door at all — mount.ts never sets `ws` and server.tsx's handler
map is a literal. Desktop ships one that cannot be mounted and will not be the last plugin to want one.

Host setup: a manifest declares packages, not a reconfigured display manager. Left manual for desktop.
Noted that a mechanism for this apparently existed months ago and was removed — history first, design
second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:53:42 +00:00
pastilhasandClaude Opus 5 0a1f1ce6df publish desktop, and stop counting node_modules as a plugin
The catalogue row and the bundled offline fallback, so the plugin installs from the marketplace like
every other. Listed honestly: its permission is owner-only at every path, and its websocket is not
mounted yet — the README and BLOCKERS.md say so rather than letting an install discover it.

marketplace-sync globs plugins/ itself rather than going through discoverPlugins, so the node_modules
skip added there did not cover it. Same fix, second place: it was reporting 15/16 and 16/17.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:42:19 +00:00
pastilhasandClaude Opus 5 b1a2d67b76 plugins: node_modules is not a broken plugin
The shared install target for plugin npm packages lives in plugins/ because bun resolves node_modules
up the tree. Discovery then found it, failed to read a manifest, and reported it as broken on every
call — a warning nobody can act on, and a denominator that made the marketplace sync report 15/16.

Mine, from the npm-dependencies work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:19:46 +00:00
pastilhasandClaude Opus 5 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>
2026-08-21 00:15:38 +00:00
pastilhasandClaude Opus 5 87603b800b os-deps: ask the owner's shell where a binary is, instead of guessing directories
`Bun.which` reads the PATH this process started with, and under PM2 that is minimal — a release binary
fetched during an install was reported missing seconds later by the same install. The fix was
prepending ~/.local/bin here.

That is guessing at somebody's environment from inside a crippled one, and guessing does not converge.
rustup puts cargo in ~/.cargo/bin, nvm puts node elsewhere, and every per-user installer adds a
directory nobody listed. The iris plugin duly wrote its own copy of the function with ~/.cargo/bin
bolted on, which then disagreed with this one: the manifest probe refused a rustup-only machine that
the sidecar could build on perfectly well.

There is nothing to guess. The platform runs as the owner, plugins are installed by the owner, and the
owner has a shell whose rc files already put all of it on PATH — that is what those files are for. So
ask it: `$SHELL -lc 'command -v <binary>'`, with the shell taken from passwd rather than
process.env.SHELL, which under PM2 is whatever started the daemon. sessions.mjs already draws that
distinction for terminals; this is the same fact applied to dependency probing.

A login shell because ~/.cargo/env and friends are sourced from profile files an interactive-only
shell may skip. `command -v` because it is a POSIX builtin whatever the shell is. Two-second timeout
so an rc file that waits for input cannot hang an install. Bun.which stays as the fallback, with
~/.local/bin prepended there and only there, for an account with no usable shell.

Exported as locateBinary so a plugin's sidecar asks the same question the install did, rather than
writing a second answer.

Verified on this machine: the login shell resolves ~/.local/bin and ~/.opencode/bin, neither of which
is on this process's PATH, and finds a binary Bun.which cannot see.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:22:29 +00:00
pastilhasandClaude Opus 5 c4042886fc api keys live in profile, not integrations
Reported by the owner, who went looking for them in user management and then in profile, and found
them in neither. They were under Integrations.

A key is not an integration with anything. It is a second way to present the authority the account
already has, and the permission registry already says exactly that: `/api-keys` is `core`, sitting
beside `/user` and `/dock`, with the comment "a key is not new authority". Profile, next to the
password it stands in for, is where somebody looks.

Moved rather than duplicated, and the route moves with it — /settings/profile/api-keys. Nothing
linked to the old path, so there is no redirect to keep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:15:36 +00:00
pastilhasandClaude Opus 5 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>
2026-08-20 22:56:10 +00:00
pastilhasandClaude Opus 5 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>
2026-08-20 22:50:13 +00:00
pastilhasandClaude Opus 5 da30d1a69e plugins: a reinstalled plugin was still being read from the deleted one
Delete jellyfin, install it again at a newer commit, connect a server — and no accounts were created,
no mapping rows written, and no error anywhere. Every step reported success.

The new checkout writes manifest.ts at exactly the path the old one occupied, and `await import(path)`
returns the module it already has: ESM caches by resolved specifier, and the specifier did not change.
So the platform kept obeying the manifest of a plugin that was no longer on disk. That manifest
predates `userProvisioning`, so the plugin was judged not entitled to the account list, the sidecar
was sent nothing, and provisioning skipped with "the platform has not sent the account list yet" —
a report that goes into the response body nobody reads.

It began working four minutes later for a reason that had nothing to do with the plugin: officer was
restarted for an unrelated deploy, which emptied the module registry.

`importFresh` keys the import on the file's mtime, so a file that changed is evaluated again and one
that did not is still a cache hit — `discoverPlugins` runs on every sidecar registration, every mount
and every plugins page load, so a Date.now() key would leak a module instance each time.

Used for the manifest, the api router and the db schema. For manifests it is complete: every manifest
imports one thing, `import type { PluginManifest }`, which is erased before it runs, so they are leaf
modules. For the other two it narrows the window rather than closing it — only the named file is
re-read, and its own static imports come back from the cache. A restart is still the only thing that
guarantees a plugin's whole module graph is the one on disk, and that is written down in the file
rather than left to be rediscovered.

The test pins the failure and not the mechanism: one case proves a plain import keeps serving a
manifest that is no longer on disk, the next proves importFresh sees the replacement, and a third
proves an unchanged file is evaluated once. mtimes are set explicitly, because two writes inside one
millisecond would otherwise decide the result.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:19:32 +00:00
pastilhasandClaude Opus 5 d6aef4cb92 plugins: delete is the second verb, and the list says what it is
Uninstalling a plugin left it under a heading that said "Installed", with a caption saying "not
installed". It never moved, so a successful uninstall read as a failed one — and nothing on screen
said the directory was still sitting there deciding what a reinstall would run.

That mattered more than the wording. Install skips the clone when the directory exists (install.ts
findPlugin, fetch.ts refusing an existing dir) and never pulls, so reinstalling jellyfin on alpha
silently re-applied a checkout from two days earlier. The provisioning code was not on the machine,
jellyfin_accounts was never created, and connecting a server did nothing at all — with no error
anywhere, because every step it did run succeeded.

Two changes, both UI:

- The heading is "On this machine", which is what the list is: every plugin DIRECTORY, installed or
  not. Delete is what moves a plugin back to the marketplace, and now the screen shows why.
- Delete is gated on being uninstalled and is a button rather than a small underlined link. It was
  previously offered in every state, so two clicks separated a running plugin from its tables being
  dropped. Uninstall, then delete — the order the buttons now teach.

Still true and not addressed here: uninstall followed by install reuses the existing directory as it
stands. The detail pane says so now; making install fast-forward a clean checkout is a separate call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:10:37 +00:00
pastilhas e37b1a151c usernames are required, and unique case-insensitively
two platform fixes that make a plugin's job smaller, both from the owner:
downstream services identify a person by name, so the platform has to be the
one holding that name to a single meaning.

`getUserByUsername` compared with `eq`, so `diogo` and `DIOGO` were two
accounts. create-user.ts asks "is this taken?" through that function, which
means the check could not see its own collision. it compares on `lower()` now.

and the constraint is on `lower(username)`, not the column — a plain unique
index is what allowed both to exist. it sits in the schema rather than only in
create-user.ts so a second code path, a migration, or someone at a psql prompt
cannot get around it.

the column is NOT NULL. `validateUsername` has always demanded one, but the
column allowed null, so anything skipping validation could write a user nobody
could name — and a Linux account or a Jellyfin login then has nothing to work
with.

both databases were checked before the change: zero null usernames, zero
case-collisions on either, so no migration was needed.

proved rather than assumed, inside a transaction that was rolled back on the
live database: `PROBEUSER` refused against `probeuser` by
uq_users_username_lower, and a null username refused by the not-null. no rows
written.

this also removes a case the jellyfin reconcile had to carry: an officer
account with no username could not be matched or created there, because
jellyfin authenticates by name and has no email login.
2026-08-20 20:03:18 +00:00
pastilhas a6e61d0c26 user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich
next — needs to know who the platform's users are. it now gets told.

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

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

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

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

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

and re-sent on every account create and delete, because the registration copy
is a snapshot: a plugin still holding a deleted user would keep an upstream
login alive for someone the platform has removed. read once per broadcast, so
two plugins cannot be told different things about the same moment.
2026-08-20 19:18:37 +00:00
pastilhasandClaude Opus 5 1cac61aa00 docs(plugins): add USER_PROVISIONING runbook
Contract for provisioning a user into a plugin's upstream service, with two
worked examples.

Four rules: never hardcode the admin credential; check-then-create (neither
API upserts); force a password change where the server supports it; verify by
actually logging in, since a 201 only means the row was written.

Immich (photos plugin) — verified end to end against a live 3.1.0 instance,
including every error body: duplicate email 400 "Email is not available",
missing field 400 validation, bad key 401. Includes a non-destructive probe
recipe to re-verify after an upgrade.

Jellyfin (jellyfin plugin) — route surface and auth model verified against
10.11.11; request bodies from working knowledge and marked as such. Differs
in shape: MediaBrowser token auth, multi-call create, no forced-password-change
flag, and accounts are per-instance (this deployment runs four independent
servers). Records the decision to set a generated password rather than create
blank-password accounts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:49:55 +00:00
pastilhas c336042b12 remove a dangling export, and write down the lockfile question
`pluginPackageJsonExists` was exported for an uninstall hook that was never
wired. an export with no caller is an unfinished thought pretending to be a
feature, so it is gone — and the behaviour it would have served is right
anyway: uninstall KEEPS the plugin directory, so its packages are still
declared and still wanted. only "delete and all its data" removes the
directory, after which the next install regenerates the merged file without it.
the packages linger until then and go on their own.

and the thing the owner asked to come back to: CLAUDE.md's frozen-lockfile rule
reads as universal, and npmDependencies does not follow it. ranges resolving
per machine is what lets two plugins share one sharp, and it was chosen
knowingly — but the codebase now holds two postures on one risk with only one
of them written down. recorded in TODO.md with the three specific things worth
settling rather than left as a feeling that something is inconsistent.
2026-08-20 18:26:27 +00:00
pastilhas 21bedf1418 npmDependencies in the manifest, installed once for every plugin
replaces the per-plugin package.json from earlier tonight. the owner's call,
and the reason is one i had not weighed: sharp is ~50 MB of platform binaries
and the obvious dependency for anything touching images, so a second and third
plugin wanting it would have meant a second and third copy with no version they
could converge on.

so every plugin's `npmDependencies` merge into ONE generated plugins/package.json,
installed into plugins/node_modules/. bun resolves node_modules up the tree, so
that directory serves every plugin and none of the platform's own code.

ranges intersect rather than alternate. two plugins asking for ^0.33.5 and
^0.33.9 produce "^0.33.5 ^0.33.9", which npm and bun read as AND — the resolved
version must satisfy both. `||` would have meant "either", which is the silent
version of this failure: one plugin quietly getting a version the other cannot
import. incompatible requirements now fail the install with bun's own message,
and both askers are named in the steps either way.

generated from the plugins ON DISK, like generatePluginSchemas, and never
edited — so a package cannot appear there without a plugin having declared it.

what this gives up, knowingly: a committed lockfile. ranges are the whole point
and a range resolves per machine, so plugin packages are resolved at install
time like osDependencies are, and unlike the platform's own. CLAUDE.md is
emphatic about frozen lockfiles and 2026-08-04, so it is named in the manifest
type rather than left to be discovered.

the generated plugins/package.json and lockfile are gitignored — /plugins/*/
only ignores directories, so files at that level needed naming explicitly.
2026-08-20 18:22:58 +00:00
pastilhas 3450f5eb57 warn when a plugin's packages would block its own updates
update is a git pull and then the ordinary install path, so the npm step is
picked up for free — a plugin that adds a dependency in a later version gets it
installed on update, with no second implementation to drift.

but pullPlugin refuses on ANY uncommitted change, and node_modules/ that
nothing ignores is one. so a plugin shipping package.json without ignoring it
installs perfectly once and can then never be updated again: the button reports
"N uncommitted changes" forever, about a directory the platform itself created,
and nobody seeing that message has a reason to connect it to a package.json
from three versions ago.

every plugin that exists today ignores node_modules, which is luck rather than
a rule — this makes it a checked one.

asked with `git check-ignore` rather than by reading .gitignore: the rule may
be nested, global, or in .git/info/exclude, and all three count.

warned, not fixed. writing into the plugin's own .gitignore would be the
platform editing a repository it does not own, and would itself dirty the
checkout — which is the exact thing being avoided.
2026-08-20 18:13:53 +00:00
pastilhas 7e654d75bc plugins can declare their own npm packages
osDependencies covered binaries on PATH — ffmpeg, radicale, tea — and there
was nothing for a javascript package. so a plugin needing one had to add it to
the PLATFORM's package.json, which is what atelier did with sharp, carefully,
with the frozen-lockfile dance documented in BLOCKERS.md.

it still broke on the first machine that was not this one. alpha pulled the
package.json, nobody ran bun install, and officer-atelier crash-looped on
"Cannot find package 'sharp'" — after the install flow had reported success.

three things were wrong with that shape. the platform carried a dependency
only one plugin needs, and uninstalling the plugin would not have removed it.
installing a plugin could not install it, so "installed" and "can start" were
different facts with nothing checking the second. and it forced the platform's
lockfile open, which is precisely the signal frozenLockfile exists to keep rare.

now: a plugin ships its own package.json and lockfile, committed in its own
repository, and the install flow runs `bun install --frozen-lockfile` in the
plugin's directory. bun resolves node_modules up the tree, so
plugins/<name>/node_modules serves that plugin and nothing else, and uninstall
takes the packages with the directory.

the presence of the file is the declaration — no manifest field, same reason
sidecar/index.ts has none: a second place to say it is a second place to be wrong.

verified by RESOLVING, not by the exit code. sharp resolves to a
platform-specific binary package, so a lockfile that installs cleanly on one
machine can leave nothing importable on another — which is the exact failure
being fixed, and checking the exit code would have reproduced it one layer up.

sharp is off the platform's package.json. the lockfile diff is that removal
plus @img/sharp-libvips-* re-resolving 1.0.x → 1.2.4, because the platform no
longer pins sharp; nothing else moved, checked line by line. the platform still
reaches sharp transitively through next, which is where it came from.
2026-08-20 18:10:08 +00:00
pastilhas b04059cdd6 untrack the atelier panel the platform should never have held
`git mv` from src/ into plugins/ staged a rename, and a staged rename beats
.gitignore — so the platform repo committed a file belonging to a plugin that
owns its own repository. the file is unchanged on disk and is committed where
it belongs, in plugins/atelier.

worth knowing generally: moving a file OUT of a tracked directory into an
ignored one needs `git rm --cached` afterwards. the ignore rule does not undo
a rename that is already in the index.
2026-08-20 17:58:28 +00:00
pastilhas 7b975784b8 atelier providers move into the plugin, where the rule already said
`IntegrationsSettings/index.tsx` has carried the rule since email was extracted
on 2026-08-18: "A plugin contributes panels, not settings sections, so it now
lives inside the email workspace itself."

the atelier extraction brief said the opposite, and the brief was mine and
wrong. sonnet found the contradiction, recorded it in BLOCKERS.md under a
heading that says "against a direct precedent", and followed the instruction
anyway — which is what it was told to do. so the wrong call got implemented
carefully rather than caught.

settings in the platform mean the plugin is not self-contained: it cannot be
configured on an older platform, and uninstalling leaves a dead section in the
settings screen for someone to delete by hand. neither is true of a panel.

the section, its import and its now-unused icon are gone from here. the screen
is `plugins/atelier/web/Providers/` and a fourth panel.
2026-08-20 17:58:06 +00:00
pastilhas 329c47c499 atelier in the marketplace catalogue
the repo exists now — gitea.officer.dev/plugins/atelier, public, master, in the
plugins org like every other one — so the bundled catalogue can name it.

BUNDLED is the offline floor under marketplace.officer.dev, not a duplicate of
it: a machine that cannot reach the marketplace still sees the plugin. the sync
picks it up from gitea on its own, verified — 14 plugins, atelier among them.

`has` says all four, which is true of it: the proxy router, atelier_* tables in
officer_db, the generation sidecar, and three panels.
2026-08-20 17:36:15 +00:00
pastilhas 7579620f81 atelier settings: enter saves the api key
the key field was an Input and a Button side by side, outside a form, so Enter
did nothing. pasting a key and pressing enter is the strongest habit there is
in a password field, and when it silently does nothing it reads as "the key
did not save" — which is the one thing this screen must never be ambiguous
about.

TODO.md tracks nineteen panels with the same defect. this is not the twentieth.

everything else in the screen was already right and is left alone: type=password,
autoComplete=off, the server never returning a key, and the field starting
empty with a placeholder that says whether one is set.
2026-08-20 16:39:51 +00:00
pastilhas 1d1a4a6fa5 atelier: extraction finished — verification, open items, no db/ recorded
Atelier is now plugins/atelier/: manifest, a thin auth-gated proxy router, the sidecar backend
(providers, the generation queue, collections, its own separate Postgres database), three panels
(Generate, Gallery+Lightbox, Collections), and Providers as an Officer settings screen instead of a
fourth panel. Its own local git repository, four commits, not yet pushed anywhere — see BLOCKERS.md.

bunx tsgo is clean. bun test: 1041->1069 pass, 0 fail, 90->92 files (the two new files are ported
tests, no regressions). The real production SPA bundle builds successfully with the plugin included
(Bun.build + Tailwind, 2.2s, zero errors) and the sidecar's HTTP API was exercised end to end against
a real, non-production Postgres database. Not done, and recorded as open: a live PM2 install cycle
against the shared production database, and publishing the plugin's repository to Gitea and the
marketplace — both cross into affecting shared systems beyond this local checkout, which is not a
call to make unattended.

The standalone officer-atelier service — what the mobile app actually talks to today — was not
touched, not stopped, and not migrated. This extraction is additive.

ATELIER-COMPLETE
2026-08-20 04:11:43 +00:00
pastilhas 6d0b2743a5 settings: Atelier providers under Integrations, per plugin decision
decision 2 of the atelier extraction: providers (which adapter, which model, whose key) becomes an
Officer settings screen rather than a panel, since it is owner-level configuration in the same shape
as the Apify token beside it. Talks to /api/atelier/providers over HTTP through useClient() — no
import from plugins/atelier/ in either direction.

recorded in BLOCKERS.md: this goes against IntegrationsSettings' own comment from the email
extraction five days earlier ("a plugin contributes panels, not settings sections"). Followed the
pre-made decision anyway per the kickoff prompt's own instructions, and wrote down the
contradiction rather than picking one silently.
2026-08-20 04:02:29 +00:00
pastilhas c8686ff191 channels: add useAtelierRefresh, for the atelier plugin's Generate/Gallery panels
same stable-bump shape as useFilesRefresh. Generate and Gallery are independent sibling panels with
no props between them (per the panel contract, components only ever receive panelId), so a finished
job reaching the gallery needs a signal rather than the standalone app's onFinished callback prop.
2026-08-20 04:01:00 +00:00
pastilhas 5854ccd4d8 atelier: map the platform, record what stays untouched, add sharp
no platform code references atelier and atelier references no platform code, so this extraction
has no reverse-dependency seam to design around. also records the database-separate decision
(collections and provider_settings both have rows on the live server) and adds sharp as a direct
dependency for the image-inspection code the sidecar needs — promoted from an existing transitive
resolution, no new package versions pulled in.
2026-08-20 03:53:30 +00:00
pastilhas 597597f50e atelier brief: push to master per task, and ATELIER-COMPLETE closes the run 2026-08-20 03:35:36 +00:00
pastilhas adc45c4343 atelier brief: port the ui, do not frame it
the first draft said follow gitea — sidecar serves its own ui, plugin proxies
it, one panel renders an iframe. wrong for this one. atelier was a separate
server because that was the quick answer at the time, and this extraction is
what undoes that: it becomes part of officer, not something officer frames.

and it is smaller than the repository suggests. 1,736 lines of react, but six
of the twenty-five files are shadcn components officer already has, and the
htmx half is eight files that get deleted rather than ported. what is left is
generate, gallery, collections and providers — about 1,200 lines.

providers is a settings screen, not a panel, so it moves to officer's settings
surface. src/servers/providers/ is already adapter-shaped, so the point of
moving it is that a SECOND provider becomes a new file rather than a new
screen. the brief says explicitly not to add one in this run — leave the seam,
say so in PLUGIN.md.

still a sidecar, for the one reason that survives the ui moving: generation is
long-running, capped by ATELIER_MAX_CONCURRENCY, and holds a credential the
platform must not. the ui is officer's; the work is the sidecar's.

added the trap that follows from all of this: "every image has a permanent
URL" was true of a standalone server with its own PUBLIC_URL, and inside
officer that url is behind the session. an <img> cannot carry a token — the
core affordance says why — so anything needing an unauthenticated image is a
blocker to record, not a route to open.
2026-08-20 03:29:05 +00:00
pastilhas 5d49f5ae32 a kickoff prompt for extracting atelier, written for an unattended run
differs from EXTRACT-A-PLUGIN-PROMPT.md in the two ways that matter when
nobody is awake.

it does not say "ask and wait", because there is no one to ask. the five
decisions that would normally be questions are pre-made with reasoning, and it
says to follow a decision even after finding evidence against it — recording
the evidence instead. a half-applied decision is worse than a wrong one
applied consistently.

and it assumes the agent will be restarted mid-run, so it says commit after
every task: a clean tree and a commit message are how the next attempt, quite
possibly the same agent an hour later with no memory of it, knows where it is.

the substantive call is the UI. atelier ships htmx AND a react spa with its own
build, and porting 84 files into panels is not an overnight job. gitea already
solved this exact problem — sidecar serves its own ui, plugin proxies it
same-origin, one panel renders it in an iframe — so the prompt points there and
says to read sidecar/rewrite.ts first, which is where the traps are recorded.

the database decision is deliberately conditional rather than fixed: count the
rows first, fold two empty tables into officer_db, keep the separate database
if either has data. losing someone's generated images to a schema decision at
4am is not a trade worth making unattended.

it opens with a worktree, and says no second agent in this tree. today two
agents with bypassed permissions ended up in one checkout and nothing was lost
only because one of them noticed and stopped.
2026-08-20 03:23:49 +00:00
pastilhas 6db26ae439 nudger: "not generating" was permission to start a SECOND agent
it fired once, in anger, and put a second claude with bypassed permissions
into a repository another agent was mid-build in. nothing was lost only
because that agent noticed the collision and stopped rather than committing
over the work. it also found a real bug while it was there, which is not a
defence of any of this.

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

three questions now, and only one answer is permission:

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

the third is blunter than it needs to be — the sidecar does not report a
session's directory, so it refuses on ANY other live session rather than on a
comparison it cannot make. that will decline some safe nudges. given what the
precise version cost, the blunt one is the right trade until the sidecar can
say where a session is running.
2026-08-20 03:17:39 +00:00
pastilhas 18bfff5e74 nudger: the due query was invalid sql, and failed silently every tick
`dueNudges` passed a JS Date as a parameter on the left of an interval
subtraction. postgres will not infer a type there and refused the whole
statement, so the tick threw once a minute and no nudge could ever fire.

the symptom was the worst kind: the row sat in the table enabled, sent_count
0, last_fired_at null — indistinguishable from a schedule that was armed and
simply had not come due yet. it was found by checking the row after arming it
rather than by trusting the log line that said "armed".

`now()` in sql instead, which also means the comparison uses the database's
clock rather than the platform's.

the test asserts only that the query EXECUTES. what broke was the sql being
invalid, and no amount of reasoning about interval arithmetic would have
caught that — only running it against a real postgres does.
2026-08-20 03:14:21 +00:00
pastilhas 12c5e51ce4 nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a
turn, and at 3am nobody types the next message. this is the schedule that does.

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

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

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

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

one nudge per (user, session) — a second schedule for one conversation is two
alarms for one sleeper. counted even when the turn fails to start, so a
refusal cannot become a tight loop. disables itself at the ceiling rather than
leaving a spent row armed. the tick starts in server.tsx rather than on first
use, because a schedule armed before a restart has to survive one.
2026-08-20 03:07:28 +00:00
pastilhas 2b69334a6c todo: the feature is called Nudger 2026-08-20 03:02:17 +00:00
pastilhas 10e30754f3 todo: a self-nudge control in the chat composer
wanted while leaving an agent to work overnight — "continue every hour if you
have stalled", beside the background-job button.

tonight's script does it from outside, polling the sessions api and speaking
over the chat websocket exactly as a browser does, which is why the platform
needed no changes. that is the workaround; this is the feature.

records the two things the platform actually lacks — a recurring trigger, and
any door into a live session that is not the chat websocket — and the three
design notes that are easy to get wrong: never speak while isGenerating, the
schedule cannot be owned by the session it nudges (a chat background job dies
with its session, taking the watchdog with the agent), and bound the loop.

the same trigger is what the watcher-loop proposal in COMMS has been waiting
for, so it is one piece of infrastructure serving two asks.
2026-08-20 03:02:10 +00:00
pastilhas adb77882b6 nudge: poke an idle chat session on a timer, never a working one
for an agent left running overnight. it is told to record blockers and carry
on rather than wait for a human, and mostly it does — but a turn that ends
leaves it idle until someone types, and at 3am nobody does.

a script rather than a queue job, deliberately. the platform has no recurring
scheduler and no way to put a prompt into a live session — `pushTurn` has
exactly one caller, the chat websocket — so this would be real work in the
queue engine AND the chat api, done tonight, in a repository another agent is
currently working in. instead it talks to the same two doors a browser does
and changes nothing in the platform. if the loop is worth keeping, it belongs
in the queue with a real trigger, and this is what that would replace.

the one rule is that it never interrupts work: `isGenerating` is checked
immediately before speaking and a working session is skipped silently. the
point is to restart a stopped agent, not to hurry a busy one.

`?all=1` on the session list, because without it the list is scoped to the
owner's home and an agent started in a project directory is simply absent —
which this would have read as "the session is gone" and stopped the night.

mints its own owner token: it runs on the box, as the owner, and a token
pasted into a shell is a token in shell history. `--max` defaults to 12, so a
loop nobody is watching ends on its own.
2026-08-20 02:53:34 +00:00
pastilhas 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.
2026-08-20 02:33:31 +00:00
pastilhas 97d1748eba email: one database per account, and the agent only knew about one
`OFFICER_EMAIL_DB` points at a single account's sqlite file, chosen by
`findEmailAccountName` as the most recently synced. that is the right guess for
"what did X say yesterday" and a silent lie for anything meaning ALL my mail:
the agent searches one mailbox and reports as though it searched every one,
with nothing available to tell it the others exist.

the layout stopped being one file per user when several addresses became
possible — DATA_PATH/<owner>/email_accounts/<address>/emails.db — and the
reference an agent reads was still describing the old shape.

so `OFFICER_EMAIL_ACCOUNTS_DIR` is handed to the mcp tool env alongside it. the
directory rather than a list, because listing it is discovery the agent can do
itself; deriving the path from a convention would mean documenting the
convention anyway, and then having two things to keep in step.

the skill now leads with the multi-account layout, names `attachment_cache` as
the entry that is not an account, and says to state which accounts were
actually covered. checked against the live tree, which has exactly those two
entries.

the index is unchanged at 608 characters — the whole point of the split is
that the body can grow to meet the truth without any conversation paying for it.
2026-08-20 01:12:40 +00:00
pastilhas 17243c2201 rescan: say when a skill exists but is not offered
the button counts skills — one directory with a SKILL.md is one skill. it now
also reports how many are actually OFFERED to an agent, which is a different
number: the loader skips any without a `description`, because a line that does
not say when to reach for something costs context in every conversation and
earns nothing.

so a skill can be present, listed, editable in the UI, and invisible to every
agent, with nothing anywhere saying why. the toast names the gap and the reason
rather than leaving two numbers to be noticed by whoever thinks to compare them.
2026-08-20 01:10:16 +00:00
pastilhas 49f7761f53 skills: publish the index, leave the body on disk
the inverse of an affordance, and deliberately so:

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

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

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

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

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

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

wired to claude code. opencode still gets NOTHING — its runner sends no system
prompt at all, and session create takes only a directory. that needs its own
channel and a guess would be worse than the gap.
2026-08-20 01:09:07 +00:00
pastilhas fe7fe49cee chat: stop reassigning a live session to fable
three defects, one shape: the code has two names for "which session is this",
and every guard checked the one no caller uses.

`fable` is the FIRST entry in the claude-code catalogue, so it is what
ModelSelector picks whenever it decides a conversation needs a default. On a
key without fable entitlement that lands as a 429, after a real mid-session
switch — the reported header read OPUSPLAN → FABLE.

1. the settings default was applied to RESUMED chats. the guard was
   `!initialSessionId`, and no chat caller passes that when resuming — the id
   arrives as `options.resumeSessionId`, and ChatPanelWrapper goes as far as
   `resume ? undefined : sessionKey`. so a user's default model was written
   over sessions that already had one. both names are checked now.

2. `hasStarted` was `!!preloadedMessages?.length`, which is FALSE for the
   first frames of a resumed chat — before its messages arrive. ModelSelector
   reads "not started" as permission to pick a default, so a reload reassigned
   live sessions during that window. a session id is known on the first render
   and a chat that has one is by definition not new.

3. harness lock. `crossesHarness` returned false when the current harness was
   unknown, which reads as "nothing crosses" and is the opposite of what is
   known — with no current harness, everything might. so a started session
   offered a switch to opencode while its model was still resolving, a switch
   that cannot work: it hands a `ses_…` id to `claude --resume` and dispatches
   to the wrong sidecar. unknown is not permission; the strip locks until the
   answer arrives.

the settings themselves were never wrong — chat.defaultModel reads
claude-code/opus on the affected server. nothing was ever reading it for the
sessions that got reassigned.
2026-08-20 00:32:44 +00:00
pastilhas ab214d1a9e rescan: count affordances, and say what a rescan cannot do
the header's rescan walks ITEM_TYPES, and affordances are not one — no
directory per item, no canonical file — so they were invisible to it. write a
new affordance, press the button, and nothing in the response mentions it.

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

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

also creates the affordances directory, so it is discoverable on a machine
that has never had one, the same way the item dirs already are.
2026-08-19 23:35:11 +00:00
pastilhasandClaude Opus 5 b14b1bf678 green that means "healthy" becomes the success token
Fifteen sites across thirteen files where a raw green stood in for a
semantic state: connection dots, health lights, completed-job ticks, the
copied-to-clipboard check, the installed badge, the checksum-matched line.
All of them meant "on / connected / succeeded", and matched each other only
because several people happened to pick the same palette number.

The values do move: --success is #16a34a where green-500 was #22c55e and
emerald-500 #10b981. That is the point — one token, so a theme can change
its mind once, and under plain.css (the leak-detection theme, which greys
--success to #404040) these now go colourless instead of staying green.

Left alone, deliberately: the confetti palette and the Start button in
start-confetti-button, the duck-avatar debug HUD, MetricCard's up/down
delta ramp, CommandBlock's check on its hardcoded dark block, the
react-email template, and the plain.css comment that names the anti-pattern.
Reds are untouched — that is a separate pass, so some ternaries now read
bg-success : bg-red-500 until it happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:35:01 +00:00
pastilhas a53b56bbf6 chat header: one green, and it is the token
the connection dot was `bg-green-500` and the bell followed it there to match.
both are `success` now — #16a34a where the palette value was #22c55e.

the raw palette value was the wrong thing for either to hold. tone.ts already
says why: once a view can reach for any of tailwind's twenty-two palettes,
green stops reading as "open" and starts reading as "the author liked green
here". two elements in one header meaning the same thing should say so by
sharing a token, not by two people picking the same number.
2026-08-19 23:24:49 +00:00
pastilhasandClaude Opus 5 0a9649a12b brand: the drawn Officer wordmark, unskewed
Replaces the old mark everywhere it is referenced — the header reads it through
--logo-src and every theme points at the same file, and the auth landing page and
the emailer read it directly, so one asset covers all three.

The CSS skew goes with it. That is where the old logo's slant came from, and
applying it to this one shears letterforms that were kerned pair by pair by hand
and signed off as finished. See landing/assets/brand/WORDMARK.md for the
construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:23:18 +01:00
pastilhas 9ca2287e50 bell: move it left, and let its colour say what it will do
the bell sat on the right between the connection dot and the end-session plug,
which put a SETTING in among the STATUS. everything on the right reports what
the chat is doing now; the bell says what it will do later. it moves to the
left of the header, on its own.

and it is coloured by preference, so the setting is readable without opening
the menu: green for every end of turn, amber for input-needed, muted for none.

green is `green-500` rather than the `text-success` token deliberately — it
has to be the SAME green as the connection dot a few elements away, and a
semantic token that drifted from it would leave two greens in one header
meaning two different things. amber is `text-warning` (#f59e0b), which is the
conditional setting: it will speak, but only sometimes.

`none` keeps the struck-through bell as well as the muted colour. colour alone
is not a state anyone should have to learn, and it is the one setting where
being wrong is silent.
2026-08-19 23:19:09 +00:00
pastilhas 34e21317da chat: the copy buttons sit on the code block, bottom right
they were sticky at the top, which put them over the first line — and on a
one-line block, which is most of them in a chat, over the whole thing.

absolute bottom-right inside the surface now, with `.code-surface` given the
`position: relative` it never had. that omission is why a control could end up
escaping to the nearest positioned ancestor and floating below the block it
belongs to.

`padding-bottom` on the pre to go with the existing `padding-right`: the last
line is the one they overlap now, and it needs its own room the same way the
first line needed it before.

the trade the old comment was worried about is real but smaller than it reads:
a block taller than the viewport now has its controls at the bottom rather
than the top. that is where you end up after reading it anyway, and it is a
worse place to be only for a block you never scroll — which is exactly the
block whose first line the old position was covering.
2026-08-19 23:14:56 +00:00
pastilhas bf57201a11 bun run doctor, and provisioning checks its own work
the tenth install in a row with something new wrong, and the pattern behind
them is not the individual faults. every step in provisionOsAccount is
deliberately non-fatal — a member whose account provisioned fine should not be
rolled back because their shell config failed. that is right, and it means
"nothing reported an error" is a far weaker statement than it reads as.

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

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

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

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

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

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

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

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

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

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

repaired on the affected server by hand first, and confirmed: home readable by
the service user, zero `effective:---` entries left under it.
2026-08-19 23:03:14 +00:00
pastilhasandClaude Opus 5 c982d82dde todo: four foreign keys churn on every push, deferred not fixed
Found while pushing chat_notification_prefs. Harmless — eight extra
statements, no prompt, no data touched — but the database CLAUDE.md told
the next person to expect only the two pk_music_now_playing lines and to
treat anything else as their own change, which is no longer true. Both
corrected; the fix itself is deliberately not done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:11:19 +00:00
pastilhasandClaude Opus 5 989c15e437 fix(chat): resume in the directory the transcript belongs to
--resume is scoped to the working directory: Claude Code files transcripts under
~/.claude/projects/<slugged cwd>/<id>.jsonl, so resuming from anywhere else fails
with 'No conversation found with session ID' — exit 1, before any output.

Conversations created before the New Chat directory picker carry no cwd, fall
back to HOST_HOME, and could therefore never resume: every message died the same
way, the banner said only 'exited with code 1', and the exit line claimed
stderr=<silent> while the CLI had printed the reason plainly.

The transcript knows its own directory — every line carries cwd — so it is read
from the file rather than guessed, first line only, since these reach 100 MB. An
explicit cwd from the caller still wins. When the same id exists under two project
dirs (a checkout that moved: officerdev/platform and officer-dev/platform both
hold one here) the newest wins and a vanished directory is skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:10:45 +01:00
pastilhas 46d621c91b chat notifications reach the phone, on the same rule as the bell
the browser bell and phone push are now two subscribers to one signal. both
ask shouldNotify(); neither owns the rule.

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

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

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

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

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

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

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

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

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

alpha was missing the table entirely — reads degraded via .catch but the PATCH
would have 500'd. pushed there too.
2026-08-19 21:53:11 +00:00
pastilhasandClaude Opus 5 73cdc2cb77 bell: notify a chat's page and list row, any chat, any time
Client half of the per-chat notification preference — DetailBar gets a bell
dropdown (every end of turn / only when input is needed / none), a global
useChatNotifications hook holds one connection to /api/chat/notify/ws for
the whole session so a background chat can still ring the bell and fire a
browser Notification, and the session list shows a badge on any row set to
notify. The server side (schema, the notify-delivery socket, the sidecar
signal at turn-end/plan-gate/question-gate) landed already in 5cba1b65,
swept in from a concurrent session sharing this working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:32:06 +00:00
pastilhas 5cba1b65a6 notify: only "the app is gone" deletes a device row
reported from the field on the first real push: it failed with BadDeviceToken
and the server deleted the registration. the build was production-signed and
the row said sandbox — a configuration mismatch, with the device and the app
both perfectly fine.

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

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

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

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

the apns log now names the mismatch rather than the reason code, since the
code alone sends people to look at the device: it prints the row's environment
and topic and says to check the SHIPPED artefact rather than the intermediate
build, which is exactly where this one hid.
2026-08-19 21:25:20 +00:00
pastilhas c6ef0b34db caldav: a missing radicale should not crash-loop the sidecar
reported from a fresh install: ENOENT spawning radicale, over and over.

two bugs, and the second is the worse one.

`spawn('radicale')` resolves against the PATH THIS PROCESS STARTED WITH, and
under pm2 that is a login-less minimum with no ~/.local/bin. the same trap has
now bitten `tea`, the plugin dependency probe and this, so the search path is
explicit here rather than inherited: ~/.local/bin, /usr/local/bin, /usr/bin,
/bin, then whatever pm2 supplied. /usr/bin is listed even though it is
normally present — it is where the apt package lands, and a PATH mangled by a
shell profile should not break the supported install.

falls back to `python3 -m radicale`, because radicale is a python package: a
pip install --user without ~/.local/bin on PATH, or a distro shipping the
module with no wrapper script, both leave it importable and unfindable. the
module is the thing that matters.

the second bug: startRadicale runs at module top level, so the ENOENT threw
and killed the sidecar, and pm2 restarted it forever. that is the opposite of
what the backoff inside it is for, and it took /_health down with it — so the
one endpoint that could have said what was wrong was the one thing not
answering. a missing dependency is now a value the caller handles: the sidecar
stays up, /_health returns 503 with the install command, and `installed:
false` distinguishes "never installed" from "crashed and backing off". both
are 503 but only one is fixed by waiting.

resolveRadicale takes an optional PATH so the null branch is tested rather
than reasoned about. verified the happy path live: radicale launched, sidecar
registered, zero restarts.
2026-08-19 21:03:14 +00:00
pastilhas 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.
2026-08-19 20:43:29 +00:00
pastilhas 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.
2026-08-19 20:30:21 +00:00
pastilhas 4cd371b55d chat: links open in a new tab
officer is a single-page app, so a same-tab navigation to an external url
unloads it — tap a link in a message and you come back to a cold boot with the
scroll position and anything mid-flight gone.

`a` was simply absent from MD_COMPONENTS, so every link rendered as a bare
anchor. all four ReactMarkdown call sites in MessageBubble share that object,
so one override covers assistant messages, user messages, tool output and the
collapsed blocks.

noopener noreferrer with it: a bare target="_blank" hands the opened page a
live window.opener back into the app. target is set AFTER the prop spread so
raw html in a message cannot override it with _self.
2026-08-19 20:23:55 +00:00
pastilhas 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.
2026-08-19 19:57:51 +00:00
pastilhas 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.
2026-08-19 19:45:56 +00:00
pastilhas 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.
2026-08-19 19:37:05 +00:00
pastilhas 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.
2026-08-19 19:07:49 +00:00
pastilhas 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.
2026-08-19 18:59:13 +00:00
pastilhas 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.
2026-08-19 18:49:15 +00:00
pastilhas ab94a3a510 notify: a member could list their phones but not register one
`personal` is matched against what remains after the permission's own prefix
is stripped, so the entries had to be the sidecar's real paths. they were
written '/devices' and '/subscriptions'; the sidecar serves '/_officer/devices'.
nothing matched, so read level permitted the GET (a safe method, allowed
anyway) and refused the POST and the DELETE — a member could see their
registered devices and neither add nor remove one.

that is the exact opposite of the comment sitting above it, which says device
registration is the caller's own. invisible so far because the owner
short-circuits every check and no member has ever been granted notify.

found while writing the app-side spec: the mobile client posts to
/api/notify/_officer/devices, which is what made the mismatch visible.

the regression test pins the real path and fails against the old value —
checked, not assumed.
2026-08-19 18:47:56 +00:00
pastilhas 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.
2026-08-19 18:41:33 +00:00
pastilhas 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.
2026-08-19 18:05:33 +00:00
pastilhasandClaude Opus 5 ab636eaa42 page title: derive from the route path instead of a hand-maintained table
RULES was a hardcoded map from every route prefix to a title, which meant a
new route silently fell through to 'Officer' until somebody remembered to
add an entry — the gap that hit installed-plugin routes, which had no entry
at all. titleForPath now takes the URL's first path segment and title-cases
it, so every route gets a sane tab title for free, including ones a plugin
adds at runtime. Trade-off: a few routes lose their hand-tuned casing
(QR Transfer -> Qr Transfer, Command HQ -> Command Hq) and settings
sub-routes collapse to the one 'Settings' title instead of a distinct one
per section — both accepted as the point of removing the table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:03:33 +00:00
pastilhasandClaude Opus 5 6235662492 fix(chat): an answered question is not a failed tool call
The answer channel is a denial — the only PreToolUse decision that can carry a
result — and the harness stamps every denied tool `is_error`. So a perfectly
good answer arrived looking like a failure: red in the transcript, and read by
the agent as something that went wrong rather than as the reply to its own
question. Confirmed on the first live question through the new channel, which
came back as `<error>Show it in Officer</error>`.

The parser already sees the tool_use that opened the question, so it records the
id and clears the flag when the matching result lands. A timeout or a lost
session lands here too and is equally not an error: its output is a sentence
explaining itself, which the agent should read rather than retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:52:44 +01:00
pastilhasandClaude Opus 5 44a3d5f655 fix(chat): give AskUserQuestion a real answer channel
The harness answered every question itself, instantly, with "The user did not
answer the questions." — before the card had painted, let alone been read. The
agent then reasoned about a refusal that never happened: on 2026-08-19 it asked
the same question three times in a row while the answers sat on the screen.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:47:28 +00:00
pastilhasandClaude Opus 5 0f56aae803 a task the server cannot find is dismissed, not reported
A chip stuck on "running" could not be stopped, killed or dismissed. Stop and
Kill both answered {"stopped":false,"reason":"no such task"} — there is no
output file, so no pid, so nothing to signal — and dismiss is only offered for a
task the tray believes has finished. A chip with no way out.

The server saying it cannot find the job is authoritative rather than an error
worth reporting: the chip was the last thing claiming it existed. Pressing Stop
now clears it.

Running chips still have no dismiss button of their own, deliberately — the
tray is the only thing watching a live job and a hidden one is a job you have no
way back to. This is the escape hatch for a job that is not actually live, and
it is reached by pressing the control you would already have pressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:41:29 +00:00
pastilhasandClaude Opus 5 9c6534c746 render markdown in your own chat bubbles, and let you copy them
Your own messages were plain `whitespace-pre-wrap`, so pasting a fenced block
gave you back the backticks and none of the formatting — the one place in the
app where markdown was written and never read.

The usual reason to leave user text alone is that rendering changes what was
typed: `a_b_c` becomes italic, a leading `#` becomes a heading. That is real,
and the answer is the Copy button beside it, which copies the RAW text. The
original is one click away and nothing you wrote can be lost to a renderer.

Same remark plugins, components and sanitize schema as the assistant's,
deliberately — a code block should look identical whoever wrote it, and the
schema already strips script, iframe, object, embed and form, so pasted HTML is
inert either way.

The copy button appears on hover rather than always, unlike the assistant's: a
reply is something you copy out of, while your own message is something you
already have, so the control earns its place only when reached for.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:02:18 +00:00
pastilhasandClaude Opus 5 4b22f52c7e update a plugin whose local branch no longer exists on the remote
The Update button failed with "couldn't find remote ref main" on every checkout
made before the plugin repositories were standardised on `master`. It pulled
`origin <local branch name>`, and the local branch and the remote's default had
stopped agreeing — so the button could never work again on exactly the installs
that most needed it.

Now `git fetch origin HEAD` and a fast-forward: HEAD resolves to whatever the
remote's default is, so the local name stops mattering, and the merge applies it
to whichever branch the checkout happens to be on.

Fourth time today that naming a branch was the bug — the marketplace icon URLs,
the capabilities sync, the setup script, and now this. The rule that keeps
holding: fetch HEAD, never a name.

Verified by reproducing it — renamed a real checkout to `main`, pulled against a
remote that has only `master`, and it succeeded.

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

Two changes, because either alone leaves half the problem.

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

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

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

Four parts.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:10:39 +00:00
pastilhasandClaude Opus 5 1e03c600b8 stop naming a branch when pulling the capabilities repository
The plugin and capabilities repositories were standardised on `master`, and
five places said `origin main` — the setup script's sync and the Update-all
path. Swapping them to `master` would be the same mistake wearing a new name.

`HEAD` resolves to whatever the remote's default is, so the question stops
being asked, exactly as it now does for the marketplace icon URLs. Only the
LOCAL branch still needs a name.

Third time today that naming a branch was the bug: the icon URLs 404'd on four
repositories created as `master`, and these would have broken the moment the
rest were renamed to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:01:46 +00:00
pastilhasandClaude Opus 5 5b486ed019 build marketplace icon URLs from HEAD, not from a branch name
Every available plugin drew a broken image on any server reading the remote
catalogue. The URLs said /raw/branch/main/, and four plugin repositories were
created on `master` — so those 404'd, and the others only worked by accident of
which branch somebody happened to use.

The comment above the builder already said "a repo whose default branch is
`master` gets a 404", marked [open]. It then happened. A warning in a comment
is not a guard.

Gitea resolves /raw/HEAD/ to whatever the default branch is, so the question
stops being asked. Verified against both spellings, and all 12 icons in the
live catalogue now answer 200.

It did not show on alpha because every plugin there is INSTALLED, and an
installed plugin's tile is served locally from /plugins/<name>/ rather than
from the catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:55:10 +00:00
pastilhasandClaude Opus 5 427bd1afff todo: close out the inputs-outside-forms sweep
Replaces the "19 files, one grep" placeholder with what the full sweep
actually found, fixed and deliberately left, and why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:54:07 +00:00
pastilhasandClaude Opus 5 994de45296 wrap inputs in real forms, so enter submits instead of doing nothing
24 panel and screen files had their fields sitting in a <div> instead of a
<form> — typing a value and pressing Enter did nothing, and the button was
the only way in. Fixed the sites that are a real form, a single field with
an obvious action, or a multi-field editor; left search/filter boxes and a
few genuinely ambiguous sites alone (see TODO.md for the full breakdown).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:51:58 +00:00
pastilhasandClaude Opus 5 319afe2c5d note that 19 panels put inputs outside a form
Typing a value and pressing Enter does nothing in any of them; the button is
the only way to submit. Found in gitea's connection screen and fixed there
only, because the rest are not one change: a search box has no submit and
wrapping it changes nothing, while a multi-field editor wants the useForm hook
rather than a hand-rolled onSubmit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:32:57 +00:00
pastilhasandClaude Opus 5 79ced03bec put ~/.local/bin on the agent's PATH
A plugin may declare a dependency no package manager carries — tea, Gitea's
CLI, is the first — and installRelease puts it in ~/.local/bin: no root, and
nothing downloaded from the internet sitting beside what the distribution
installed.

But PM2 hands the platform a login-less PATH
(/usr/local/sbin:...:/snap/bin), so the agent it spawns inherited one too, and
the binary the platform had just installed was invisible to the agent it was
installed FOR.

Which is exactly what happened on the first real test: gitea's affordance told
the agent to use `tea`, the agent tried, and got `command not found` for
something sitting in the owner's home. The affordance worked perfectly and the
advice it gave was impossible to follow.

Appended rather than prepended, so a system binary of the same name still wins
— `identify` in os-deps is what settles which one is really wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:52:01 +00:00
pastilhasandClaude Opus 5 09f2cfa267 a plugin can depend on a release binary, and prove it is the right one
osDependencies could only say "this package, from this manager". Gitea's `tea`
is not in any distribution — it is a Go binary from dl.gitea.com — and Debian
has an UNRELATED package of that name: a graphical text editor. So the one
dependency gitea actually wants could not be expressed, and its marketplace
page showed no dependencies at all while music's showed two.

`release: { url, identify }` covers it. {os} and {arch} are filled from the
host in the spelling Go releases use, and the binary is fetched to
~/.local/bin — no root, and nothing a plugin downloaded from the internet
sitting beside what the distribution installed. Written to a temporary name and
moved into place, so a half-downloaded file is never on PATH under the real
one.

`identify` is the part that matters. Bun.which('tea') cannot tell Gitea's CLI
from the text editor, so presence was never the same question as identity —
and reporting the wrong program as satisfied is worse than reporting it
missing, because the install succeeds and every call afterwards fails
strangely. A binary that fails to identify is now `present: false, imposter:
true`.

Found while testing: Bun.which() with no options reads the PATH the PROCESS
started with, which under PM2 frequently excludes ~/.local/bin entirely. A
release binary fetched during an install would have been reported missing
seconds later by stillMissing, failing an install that had just succeeded. The
search now passes PATH explicitly, with ~/.local/bin first.

Verified against the real binaries on this machine: the genuine tea identifies,
a fake that prints text-editor output is caught as an imposter, and both
dl.gitea.com URLs (linux-amd64, darwin-arm64) answer 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:48:18 +00:00
pastilhasandClaude Opus 5 51afa40a19 affordances: officer/core and officerdev/<plugin> are both right
The path takes `publisher` from the manifest verbatim, so a plugin published
by officerdev lands under officerdev/. `officer/core` is not a plugin at all —
it is the platform's own, shipped by the setup script from the capabilities
repository.

Two spellings marking two different things: platform versus publisher. Arrived
at by accident and kept on purpose. Recorded because the obvious tidy-up
removes the distinction rather than the inconsistency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:24:57 +00:00
pastilhasandClaude Opus 5 8fe1b1824b a plugin can ship affordances, and installing publishes them
A plugin is a new SURFACE arriving at runtime, which is exactly the case
hand-written prose could never cover: install gitea and no agent learns that
`tea` is authenticated, or that plain `git` is the better tool for what git
already does. The plugin knows; nothing carried it across.

An `affordances/` directory in the plugin repository is copied to
capabilities/affordances/<publisher>/<plugin>/ on install and removed on
uninstall — the same shape as assets/ and publishAssets, deliberately, because
two mechanisms for "the plugin ships some files" would drift and that one is
already understood.

Namespaced by publisher because two plugins named `gitea` from different
publishers must not overwrite each other, and because an affordance in the tree
should say whose it is without being opened. It mirrors mountPrefix.

Uninstall removes the PLUGIN's directory, never the publisher's: every
first-party plugin publishes under one officerdev/, and taking that level out
because one was uninstalled would strip the rest silently. That is what the
tests are mostly about — a copy going wrong is obvious, this would not be.

Publishing is not fatal to an install. An affordance that failed to copy costs
an agent some knowledge; refusing the install over it costs the owner the
plugin.

Verified against the real gitea plugin: published, the loader saw
officerdev/gitea/gitea beside officer/core/image-rendering, uninstall removed
it and left core alone.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:59:10 +00:00
pastilhasandClaude Opus 5 d8cccad84c Revert "dock: Gitea gets the teacup"
This reverts 061e2137.

The artwork is right, the location is not: gitea is about to become a plugin,
and a plugin carries its own icon in assets/ — published to /plugins/<name>/ on
install, and removed when it is uninstalled. Putting it in the platform's
public/icons/ makes it the one plugin whose tile the platform ships, and it
would keep being served after an uninstall.

Added here only because the plugin repository did not exist yet. It does not
yet, either — this goes back in as plugins/gitea/assets/icon.png once it does.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:34:30 +00:00
pastilhasandClaude Opus 5 061e213780 dock: Gitea gets the teacup
git + tea, drawn to the icon brief: a green voxel teacup with the branch diagram
pressed into the glaze as a maker's emblem, and Officer Duck on the rim.

Core dock item with no phone app behind it, so there is an icon and no splash —
a splash belongs to something that launches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:30:00 +01:00
pastilhasandClaude Opus 5 057bf7b183 load affordances and append them to every claude session
The platform hosts the agent and never introduces itself. An agent gets a
shell, a filesystem and a prompt; everything about the CLIENT on the other end
has to be guessed. It writes ![](/home/me/chart.png), nothing renders, and
nothing says why — because an <img> carries no bearer token and it is the
user's DEVICE that fetches the URL.

servers/affordances/load.ts reads capabilities/affordances/ and compiles one
block; claude-manager passes it as systemPrompt.append with the claude_code
preset, so the harness keeps its own prompt.

Passed there rather than written to a file because it is the one place every
session goes through, owner and member alike. A generated CLAUDE.md cannot do
that — user-instance.ts deliberately refuses to write one for the owner, since
it would pollute the personal ~/.claude/CLAUDE.md they maintain themselves.

Read per session, not cached: capabilities/ is already read fresh each session,
so a plugin installed an hour ago is live on the next message. Synchronous
because createSession is not async, and making it so to read a few kilobytes
would ripple through every caller.

No filtering. Everything is loaded into every conversation, because the axis to
filter on is not one dimension — for image rendering it is what the CLIENT
renders, for gitea what TOOLING exists on the machine. Fine at two files, not
at twenty; TODO.md carries the argument.

Opencode gets nothing, and that is recorded as a gap rather than hidden: an
affordance that holds on one harness is the drift this removes. Deliberate —
opencode is paused pending v2 and the work would be thrown away.

Nine tests, aimed at the failure that matters: a file that silently does not
reach the agent looks exactly like one nobody wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:27:01 +00:00
pastilhasandClaude Opus 5 d8c97b6814 affordances: the file format, where they live, and what is still open
Design only — nothing reads any of this yet.

Markdown with YAML frontmatter, like every other item in capabilities/.
Frontmatter is for the PLATFORM, the body is for the agent: `name` becomes the
heading in the compiled block, `description` says when to reach for it and is
not sent, `version` is an INTEGER to match capabilities/agents/ rather than the
semver plugins use. A plugin-contributed affordance sits near both numbers and
they mean different things — the integer is the revision of that text, the
semver is the plugin's.

They live at capabilities/affordances/<publisher>/<plugin>/, with `core` in the
plugin slot for the platform's own. A plugin ships an affordances/ directory
and install copies it in — the same shape as assets/ and publishAssets.

No `surface` field. It was drafted and dropped: for image rendering it would
mean what the CLIENT renders, for gitea what TOOLING exists on the machine.
Two axes wearing one name. Everything loads unconditionally until that is
worked out, which is fine at two files and will not be at twenty — and this
codebase has already been bitten by a field that validated itself and was never
consumed (unauthenticatedPaths).

TODO.md carries the build list and the two deferred questions, including the
honest one: "declared where implemented" is the rule, and a hand-authored file
in a repository is not that. Accepted because prose reaches every harness and a
registry reaches one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:19:40 +00:00
pastilhasandClaude Opus 5 7a9e576ee6 name the thing the platform never tells an agent: affordances
DESIGN, NOT BUILT. No registry, no API, nothing to call. Written down now
because the name and the boundary are the parts that get expensive later, and
because a plugin author who invents a different word for this is hard to merge
with afterwards.

An affordance is what the surface on the other end offers the agent — declared
by the code that implements it, read at runtime. Not what the agent can do;
what the client will honour.

The worked example is images. Officer's chat DOES render markdown images, but
an <img> carries no bearer token, so it can only load what the browser can
fetch unauthenticated. A fresh agent knows none of that: it writes
![](/home/me/chart.png), nothing renders, and nothing says why. That knowledge
currently lives as prose in a hand-written CLAUDE.md on one machine, phrased as
a workaround ("use a public URL") rather than as the constraint — with nothing
keeping the two in sync.

Prose is the wrong container for three reasons: it drifts from the code, it is
per-machine, and a plugin cannot contribute to it. The last one is the point —
a plugin IS a new surface arriving at runtime, and no agent learns it exists.

The rule that makes it worth building: an affordance is declared where it is
implemented, like permissions/registry.ts. A list maintained elsewhere is the
CLAUDE.md problem again with a better name.

`capability` and `skill` were both already taken here, twice in skill's case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 06:52:29 +00:00
pastilhasandClaude Opus 5 839199bf53 dock: Calendar and Contacts carry their artwork, not a glyph
Both were redrawn to the icon brief today, so the dock shows the same voxel mark
the phone apps do rather than a lucide CalendarDays and Contact.

tile: 'bare' for the same reason a plugin's artwork gets it — the art brings its
own frame, and a coloured square behind it reads as a mistake. color stays
because the active glow, the indicator dot and the settings tint all still use
it.

These two are core rather than plugins, so the files live in public/icons/
alongside the duck rather than arriving through publishAssets.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 06:11:42 +00:00
pastilhasandClaude Opus 5 8f15f16d1c restore dav_app_passwords to the db:push barrel
Missed when calendar and contacts became core in 9c309c5a: the routers were
uncommented but `export * from './dav/schema'` was not, so drizzle never saw
the table and bun db:push never created it. Alpha surfaced it as an unhandled
"Failed query: select ... from dav_app_passwords" on every boot.

The table holds the DAV app passwords a phone authenticates with, so the sync
door was mounted, answering 401 with a correct WWW-Authenticate header, and
incapable of ever accepting a credential — nothing could mint one.

Same shape as the hazard already recorded for the wallet, soulseek and
jellyfin: a schema commented out of the barrel is invisible to db:push, which
silently means the table is never created and, where one already exists,
that it would be dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:32:24 +00:00
pastilhasandClaude Opus 5 cfbec0b757 plugins: a splash is part of a plugin, like its icon
assets/splash.png is discovered by presence, published by the same copy that
already took the whole assets directory, and carried on DiscoveredPlugin beside
icon.

The platform draws no splash — a plugin has no launch. It is carried because the
plugin repository is where a product's artwork lives now, and the phone app that
fronts the same product needs somewhere to read it from that is not a designer's
folder. The pair is the point: the icon is a reduction of the splash's world,
and keeping them together is what stops them drifting.

Publishing no longer gates on the icon alone, which left a plugin shipping only
a splash serving nothing.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:18:44 +00:00
pastilhasandClaude Opus 5 f2c6ebaa4d calendar and contacts get dock tiles again
Their tiles were contributed by the app-store `caldav` catalogue entry, so
deleting that entry in 9c309c5a took them with it. The routes kept working
while the dock and the dock settings list went silent — the same
silently-half-working shape as the plugin permission collision, arrived at from
the other direction: there the surface existed with no route, here the route
exists with no surface.

Now in CORE_DOCK_ITEMS, which is read by BOTH DashboardLayout and
DockSettings — one list, so the dock and its settings screen cannot disagree
about what exists. That is also why the user saw both symptoms at once.

Calendar joins DEFAULT_DOCK_PATHS since it is now baseline functionality every
install has. Contacts deliberately does not: it is available in dock settings,
but two tiles for one sidecar in everyone's default dock is a crowded default
rather than a useful one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:39:15 +00:00
pastilhasandClaude Opus 5 d6f093532d install radicale and register officer-caldav on an existing server's next boot
Calendar and contacts became core in 9c309c5a. A FRESH install gets radicale
from machine-setup and officer-caldav from CORE_PROCESSES; an existing one gets
neither from a git pull, because a pull changes nothing outside the repository.
So without this, pulling that commit leaves /calendar mounted, rendering, and
talking to nothing — the silent half-working failure this codebase keeps
getting bitten by, and the same shape as the plugin permission collision fixed
in ee321f49.

Best-effort and AFTER serve(), never blocking: a held apt lock, an unreachable
mirror or a machine without passwordless sudo must not stop Officer starting.
Uses the existing os-deps machinery rather than a second spelling of "install a
package" — including `sudo -n`, which fails fast instead of hanging on a prompt
no one can answer under PM2. When it cannot install, it logs the exact command
to run by hand.

addCoreProcess is new and deliberately narrow. The platform normally never
writes a core entry — it does not know the core list, and duplicating
CORE_PROCESSES here would be a second copy to drift. The exception is exactly
this: a commit that MOVES something into the core set, which an existing
install has no other way to learn about.

Verified on this machine rather than assumed: radicale was absent, the guard
installed it (3.1.8), and a second run reported `present` and wrote nothing.
The install path is not unit-tested on purpose — a test that really ran
apt-get would modify the machine running it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:28:17 +00:00
pastilhasandClaude Opus 5 9c309c5ab6 calendar and contacts are core again, with radicale as a plain sidecar
Reverses the 2026-08-13 decision to make caldav an app-store entry. Three
reasons it does not belong there.

It is not an integration. Photos-via-Immich or torrents-via-Transmission assume
you already run the thing; a calendar assumes nothing, and a personal server
without one is missing a baseline rather than an optional app.

It needs TWO top-level routes, /calendar and /contacts, and a plugin gets one —
derived from its appName, which is what makes it structurally impossible for a
plugin to shadow a core route. Nobody thinks "I'll open my dav", so collapsing
them under /dav was the wrong trade, and weakening the namespace guarantee to
allow two was a worse one. As core routes the question does not arise.

And it never actually needed Docker. Radicale is a ~2MB Python package;
sidecar/caldav/radicale.ts already spawns it directly on loopback with
http_x_remote_user. The container came from the app store making everything a
container, not from anything Radicale requires.

So: the routers are mounted again (/caldav for the UI's JSON door, /dav for app
passwords, the top-level /dav sync door and the two .well-known redirects),
officer-caldav joins CORE_PROCESSES, radicale joins the machine-setup package
list for all four package managers, and the catalogue entry is gone.

members.test.ts re-anchored a fifth time — caldav was one of two `accounts`
services and memos is now the last. Noted in the test that the sixth time
should be a literal fixture instead.

Verified: assertPermissionTotality accepts /caldav and /dav, tsgo clean,
1085 pass. NOT yet verified against a real client — radicale is not installed
on this machine.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:47:54 +00:00
pastilhasandClaude Opus 5 ee321f49fd refuse a plugin install whose permission key the platform still claims
Three times in one day — photos, soulseek, jellyfin — the same failure on the
same machine: install reports success, the dock tile appears, the frontend
rebuilds with the route in it, and every request 404s. The plugin never mounted,
because setPluginPermissions refused its key as already core.

That refusal was correct and useless. It happens at mount time, after the
install has reported success, and its only trace is one line on stderr that
nobody is reading. The cause is always the same: the key was a CORE permission
until the extraction commit, so a server whose platform predates that commit
still owns it. The fix is always git pull, and nothing in the product said so.

installPlugin now checks the manifest's keys against the core registry before
anything is written, and refuses with a message naming the colliding keys and
telling the operator to pull the platform and restart. Placed with the other
pre-flight checks, so it changes nothing that would need undoing.

corePermissionCollisions is exported from the registry rather than reimplemented
in install.ts — the core key set has one owner.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:55:35 +00:00
pastilhasandClaude Opus 5 c4b262dc51 provision-user-dirs script now sets modes, by calling the real provisioner
The script imported DATA_PATH and USER_DIRS from data-path.ts and then
reimplemented the mkdir loop without the chmod that module's own
provisionUserDirs applies — 711 on the account root, 700 on each subdir. A
hand-provisioned account would have come out 755 at the default umask, where a
UI-provisioned one is closed. Its header comment warned about exactly this
drift while being an instance of it.

Nothing was ever created that way here: every account on this box was made by
the create-account handler and is correctly 711/700. This is a latent
divergence between two paths that claim to produce identical accounts, not
damage to repair.

Calls provisionUserDirs(email) instead. Two consequences worth naming:
re-running now re-asserts modes on existing directories, so it is also the
repair; and DRY_RUN skips the call outright rather than gaining a dry-run mode
in the real provisioning path to serve a script.

Verified: dry run creates nothing, a fresh root comes out 711/700, and a
directory chmod'd to 755 is restored on the next run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:07:56 +00:00
pastilhasandClaude Opus 5 8a4b96daf8 fix question options rendering permanently disabled
AskUserQuestion has no answer channel: it resolves itself the moment it is
called, returning "The user did not answer the questions." before the buttons
have painted. QuestionActivity gated its options on `message.output === undefined`,
so that self-resolve disabled every option on every question, always. Reported
from real use — muted text, no-entry cursor, nothing clickable.

The output means the tool call is over, which is true and irrelevant. The answer
travels back as an ordinary chat message. `sent` is the only real latch.

The test was the worse half: it asserted the greyed-out state was correct, using
the literal self-resolve string as its fixture. Code and test were wrong in the
same direction, so a green suite sat on top of a feature that had never once
worked. Inverted it, and verified it fails against the old logic rather than
just passing against the new.

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

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

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

── Test fixtures, repointed rather than deleted ──

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

── Vocabulary swept, per the runbook ──

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

bunx tsgo clean. 1080 pass / 0 fail.

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

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

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

── Test fixtures, repointed rather than deleted ──

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:52 +00:00
pastilhasandClaude Opus 5 7819eff6a1 chat: render AskUserQuestion, instead of dropping it as raw JSON
The model has been asking questions in Officer all along. None of them ever
rendered.

MessageBubble dispatched the interactive question UI on
`toolName === 'question'` — OpenCode's name for it. Claude Code's tool is
`AskUserQuestion`, so every question from a Claude session fell through to the
generic ToolActivity and printed its input as JSON in a bubble. Nothing threw,
nothing logged, and the payload was arriving correctly the whole time, so it
read as "the model never asks" rather than as a bug. Both harnesses send the
same shape under different names; only the name was ever wrong.

── What this deliberately does NOT do ──

It does not make the agent wait. Checked against the installed SDK rather than
assumed: AskUserQuestion is implemented inside the bundled cli.js (with its own
"No answer provided" fallback), appears nowhere in sdk.d.ts, and there is no
control method or hook that can return a tool RESULT. canUseTool and PreToolUse
can only allow or deny, and canUseTool is shadowed entirely under
bypassPermissions, which is how Officer runs. So the tool self-resolves as
unanswered the moment it is called, whatever the client does.

The two ways to pretend otherwise — forging a tool result, or smuggling the
choice through a denial reason — both lie to the model about what happened.
Neither is here. The question is rendered, and the answer goes back as the next
message, exactly what a person would have typed. The tool contract is untouched.
The cost is real and stated in the file: the agent will usually have carried on
and reads the answer a beat late. The day the SDK grows an answer channel, this
component is the piece that already knows how to ask.

── Also fixed while in there ──

State was single-valued and shared across questions, but the schema allows up to
four. With more than one, the first click sent a partial answer and locked the
rest. Now keyed by question index: nothing is sent until every question has an
answer, and a multi-question reply is labelled by header so it cannot be
ambiguous. Multi-select joins with commas, which is what the tool's own output
schema documents.

`multiSelect` (Claude Code) and `multiple` (OpenCode) are normalised in one
place rather than at each read site.

6 tests pinning both harness shapes, the multi-question rule, the client-side
"Other" escape hatch (both schemas tell the model not to offer one, because
providing it is the client's job), and that an already-resolved question is
read-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:01:03 +00:00
pastilhasandClaude Opus 5 c13271a631 runbook: record the hand-rolled-proxy bodyParser trap
Cost an afternoon on the vault extraction and presented as far as possible from
its cause: every POST through /api/vault answered "sidecar unreachable" while
bodyless GETs worked, so it read as an intermittently dead sidecar rather than
a consumed request stream.

bodyParser runs across the protected router and reads JSON and form bodies,
disturbing the stream; a catch-all that then forwards ctx.req.raw.body forwards
something already consumed, Bun's fetch throws, and the catch reports the
sidecar as down.

createSidecarProxy registers its prefix itself, so core sidecars and any plugin
using it never meet this. Vault proxies by hand and the extraction dropped the
registration. Found by the mobile dev, who went looking on the server side
after the app kept failing where the browser extension did not — the split was
the clue, since the extension's routes are mounted above the protected router
and never see bodyParser at all.

Verified before recording: registerProxiedPrefix and isProxiedPath do what the
fix assumes, bodyParser consults them, create-proxy.ts:83 registers for core,
no vault handler reads ctx.get('body') (all three call ctx.req.json()), and the
registered prefix covers every path the mobile app uses while still not
matching /api/vaultwarden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:29:40 +00:00
pastilhasandClaude Opus 5 8367fada13 plugins: let a plugin claim top-level paths and divert /api by header
Restores the setup the vault extraction regressed: paste the bare Officer URL
into a Bitwarden client and log in. Nothing else.

The extraction moved the Bitwarden door to /api/vault/bw, which is correct and
unusable — a client derives /api, /identity, /icons and /notifications from ONE
url it is given, so a prefixed base turns setup into five fields copied by hand
into an advanced pane. That is not a feature anyone completes, and the owner
said so. My regression, my wrong call: I treated "a plugin cannot serve
top-level paths" as a fact about the platform when it was a fact about what had
been built.

Two additions, both manifest-declared, both narrow:

  unauthenticatedRootPaths  single lowercase segments (`/identity`), served
                            above the gate. Refused if reserved (`/api`,
                            `/auth`, `/plugins`, `/dav`, …), refused if another
                            plugin already claims it, refused if it is anything
                            more structural than one name.

  protocolHeaders           hands `/api/*` to the plugin when the request
                            carries one. Needed because /api is the one
                            namespace both servers claim — Vaultwarden has
                            /api/sync, Officer has /api/chat — and the path
                            cannot tell them apart, so the client says who it
                            is. ROUTING, not authentication: a forged header
                            reaches the plugin, which demands its own
                            credential, and cannot reach anything of Officer's.
                            The worst it does is cost that caller Officer's API.

The Bun route table gets an entry per claimed segment, built from the plugin
set — `refreshPluginMounts` runs at line ~204, long before serve() at ~378, so
the claims are known. This is the constraint I earlier asserted made the whole
thing impossible, without checking it. The real cost is smaller and stated in
the code: a plugin claiming a root path is not fully installed until
`pm2 restart officer`, because the table is fixed once serve() is called.

Every claim is logged at boot by plugin — a root path answering without a
session should not be something you read a manifest to discover.

13 more tests (30 in the file): reserved segments, malformed segments, two
plugins colliding, and segment-not-substring matching so `/identityx` never
matches `/identity`. Suite 1074 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:47:04 +00:00
pastilhasandClaude Opus 5 dc190bb790 plugins: implement unauthenticatedPaths — the half that was never built
`unauthenticatedPaths` has been in PluginManifest since it was designed, with
validation (`publicPathProblem`), a long docstring, and a promise that
`assertPluginPublicPaths` re-checks it at boot. Nothing read the field. No
function by that name existed. Kiosk, the plugin it was written for, declares
none — so it validated, documented itself, and did nothing, and no plugin has
ever actually been served above the gate.

Vault is the first real user: a stock Bitwarden client speaks Vaultwarden's
protocol and has nowhere to put a platform JWT, so it cannot use the
authenticated /api/vault router at all.

── What was missing, and where it had to go ──

Two places, not one, and the second is the one that matters:

  • the permission gate skips declared paths — but that gate only runs when a
    token WAS presented (`payload && !isOwner && …`), so it is what stops a
    valid non-owner reaching something ungranted.
  • `protectedRouter.use(userMiddleware)` is what stops an ANONYMOUS request.
    A path meant to answer without a session has to skip that too, so it is now
    conditional on the same predicate. Fixing only the gate would have left
    every declared path returning 401 — passing its own tests and failing in
    the one case it exists for.

── Containment ──

Paths resolve against the plugin's own mount (`/api` + mountPrefix + path) and
containment is re-checked there, so a plugin cannot name `/auth` or another
plugin's routes however it edits its manifest. Refusal is per-path: one bad
entry does not take the good ones with it, and each is logged by name.

Matching is on segments rather than characters — `/api/vault/bwx` must not
match `/api/vault/bw/*`, the same class of bug `permissionForApiPath` guards
against with `/user` vs `/username`.

Every plugin with public paths is logged at boot: a route that answers without
a session should not be something you have to read a manifest to discover.

17 tests, pinning the containment rules rather than the happy path. Suite
1061 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:26:35 +00:00
pastilhasandClaude Opus 5 5e4f3fe29f fix all seven failing tests — every one was a stale assertion, not a live bug
The suite has been carried at "1038 pass / 7 fail, same as baseline" for long
enough that the count became the thing being checked instead of the tests. All
seven turned out to pin a contract that changed on 2026-08-13, when a batch of
features were switched off pending plugin extraction. None was a defect in
running code; two were tests that could no longer detect what they were written
to detect.

── Five in registry.test.ts, one root cause ──

Switching a feature off comments out its `api:` line but keeps the permission
(the frontend route still needs gating, and existing role grants have to keep
meaning something). Eleven permissions therefore declare no API prefix now.

  • `REAL_WS` still listed 'desktop', which server.tsx stopped serving. The
    file's own header says it mirrors that map by hand and that this is "the
    drift this file keeps catching" — it caught it, and the failure was read as
    baseline for five days.
  • 'refuses a claim on a router that no longer exists' withdrew /gitea, which
    claims nothing any more, so nothing threw. The test could not fail for the
    right reason OR pass. Re-pointed at /tasks, which is core and mounted.
  • Two path→permission tests asserted /api/gitea and /api/caldav resolve.
    Re-pointed at /tasks and at /user vs /users — the latter is a live overlap
    AND a real privilege boundary (resolving /api/users to 'account' would hand
    the ordinary-account permission the user-administration API), where the old
    /dav vs /caldav pair mapped to one permission and never showed the property.
  • 'every permission declares at least one api prefix' stopped being true by
    design. Replaced with what it was protecting — a permission must gate
    SOMETHING reachable, api or route, since one gating nothing can be granted
    and revoked to no effect while reading as a control. `notify` is named as
    the one genuine exception rather than silently tolerated: no API, and no
    screen by design.

── plugins/music/cliamp: a real ordering fix, pushed separately ──

openPlayer checked for the cliamp binary BEFORE validating the path, so a path
escaping the owner's home answered "cliamp not found on host" on any machine
without it. Fixed there (3f4ee82): validate the request, then inspect the host.

── The pty timeout: an env var that had done nothing for five days ──

The test pinned `HOME_DIR: '/tmp'`. `HOME_DIR` was removed from the platform on
2026-08-13 when the install root became derived, and sessions.mjs resolves `~`
with node's `homedir()` — which reads `$HOME`. So the shell opened in the real
home, `pwd` never printed /tmp, and the test spent its full 15s timeout waiting
for output that could not arrive. One character class of a rename, five days
ago, costing 15s of every suite run since.

1044 pass, 0 fail, 85 files (unchanged). Suite is ~15s quicker for the obvious
reason.

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

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

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

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

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

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

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

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

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

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

── Tests ──

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:43:32 +00:00
pastilhasandClaude Opus 5 c775e8c7e3 re-enable vault — plugin extraction deferred, this is the same code as before
Reverses 1eecc400 ("switch off Vaultwarden's routers, pending extraction into
a plugin"). That plan is on hold: vault, unlike offscale/email/music, is a
Docker-provisioned service through the old app-store catalogue
(sidecar_installs, composeTemplate: 'vaultwarden') rather than a self-
contained sidecar, and the new plugins/ system has no equivalent yet for
provisioning a container. Design work for that is separate, future, explicitly
deferred by the owner rather than attempted here.

What's actually needed right now is narrower: the owner's own Vaultwarden
instance is already running, untouched, externally. This restores exactly the
code that was there before 2026-08-13 — both routers (owner-authenticated
/api/vault for Officer's own clients, unauthenticated /vaultwarden + root
Bitwarden paths for third-party clients), the websocket notifications hub, the
sidecar's port-capture side-effect import, and the vault schema export. All of
it was left commented rather than deleted specifically so this restoration
would be a straight uncomment, per the original commit's own reasoning.

Verified before touching anything: no data was at risk. vault_tokens/
vault_unlock_keys did not exist in Postgres (dropped by a db:push after the
schema was commented out, before this session), and there is no
sidecar_installs row for vault on this machine — nothing to lose, nothing to
migrate.

Confirmed the auth split resolves cleanly without any core changes:
vaultRouter (/api/vault) does its OWN userMiddleware + owner-only gate
internally, so it needs no special exemption — the totality.ts entries for
'/vault' and 'vault' were already present, unaffected by the mount being
commented out, and needed no changes here. Only the unauthenticated
publicVaultRouter genuinely depends on the EXEMPT_API_PREFIXES/
UNPROTECTED_API_PREFIXES carve-out, which is explicitly documented in-code as
TEMPORARY: it goes away once a first-party browser extension authenticates to
Officer directly instead of to Vaultwarden.

bunx tsgo clean, suite at its 7 pre-existing baseline failures, no new ones —
the code had not drifted while dark.

Still needed, not done here (deliberately left to the owner, same as every
restart this session): set VAULTWARDEN_URL in .env to the real server address,
add an officer-vault PM2 entry (ecosystem.config.cjs has none — this was never
in CORE_PROCESSES and there is no plugin/sidecar_installs row driving it),
bun db:push to recreate vault_tokens/vault_unlock_keys, restart officer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:15:22 +00:00
pastilhasandClaude Opus 5 3803d42243 plugins: don't require a literal .git suffix on a catalogue clone URL
Found chasing a real install failure: music's marketplace row was hand-
entered as https://gitea.officer.dev/plugins/music with no .git suffix, and
fetchPlugin's URL check refused it outright with a message ("https URLs
only, no credentials") that named neither actual reason.

The suffix was never a security property — https-only, safe character set,
no embedded credentials are the real checks, and Gitea/GitHub both clone
identically with or without .git. Made the suffix optional rather than
required, so a future hand-entered row missing it doesn't hit the same wall.

Also fixed the row itself in officer_marketplace (added .git, matching every
other plugin's entry) for consistency, and confirmed the gitea repo itself
was never the problem — plugins/music already exists there with both
commits, unaffected by this bug.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:18:17 +00:00
pastilhasandClaude Opus 5 eed906f45b command hq: show token totals alongside cost, not just dollars
Both the grid card and the detail meta bar now read "N turns · 12.3k tok ·
$0.20" — tokens first, since that's what was actually asked to be primary,
dollars kept alongside rather than dropped. formatTokens abbreviates past
1000 (12.3k, 1.2M) the same way a long-running watcher's cost already reads
at a glance; the raw sum stops being readable well before the dollar figure
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:07:01 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 09:41:13 +00:00
pastilhasandClaude Opus 5 7b7d8bbf36 add gen:ecosystem script to rebuild ecosystem.config.cjs from the database
bun gen:ecosystem writes the whole file from three sources: CORE_PROCESSES read
directly out of scripts/setup/officer-setup/lib/services.sh (shelled out to bash,
so there is exactly one list rather than a TS copy to keep in sync by hand),
marketplace plugins that are installed AND enabled in Postgres and actually ship a
sidecar/, and app-store services that are installed AND enabled, using each
catalogue entry's `process` name against the src/servers/sidecar/<id>/index.ts
convention.

Extracted `pluginPm2Entry` (a pure DiscoveredPlugin -> Pm2App) out of
addPluginToEcosystem so the live install/uninstall path and this from-scratch
rebuild share one definition of what a plugin's entry looks like, rather than two
that can drift apart. addPluginToEcosystem's own behavior is unchanged.

Complements, not replaces, the existing incremental add/remove in
servers/plugins/ecosystem.ts — that edits the live file at install time; this
regenerates the whole thing, for a fresh clone, drift, or restoring from a backup.

Found while testing against this machine's real file, worth flagging rather than
silently working around: officer-marketplace (the marketplace.officer.dev process,
src/servers/marketplace/server.tsx) is in this machine's ecosystem.config.cjs by
hand and is modeled by neither the plugin system nor the app-store catalogue, so
this script does not know about it and would drop its entry on a real run. Left
alone rather than guessed at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:23:25 +00:00
pastilhasandClaude Opus 5 9e641bc228 COMMS: reply to mobile-4 — cost-tracking schema change, preamble proposal deferred
Tells the mobile side what moved under fields they already reference:
watcherLastFiredAt is no longer a column (command_hq_watcher_fires /
command_hq_turns hold what it summarized), watcherArmedAt/watcherStoppedAt
are new, and two routes (.../turns, .../fires) expose the raw series. Same
argument-order bug flagged as a general caution.

Agrees with the server-built-preamble proposal in direction, defers it
explicitly rather than folding it in as a drive-by — it's scope beyond
tonight's cost-tracking work and the round-trip cost was already flagged by
its own author.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:03:52 +00:00
pastilhasandClaude Opus 5 56911f6874 COMMS: the self-re-arm evidence is stronger than either write-up said
Andre's own count: at least 10-15 runs, before and after the current mechanism
existed, zero failures. Both notes had it as "hours, this session".

Recorded as a correction because it is a different class of claim. "It worked for
hours tonight" supports a decision; "it has never failed across a dozen runs and two
surrounding mechanisms" supports not revisiting it — which matters, because
self-re-arm looks fragile on paper and the instinct of anyone reading it cold is to
add a safety net the evidence says is unnecessary.

It also justifies the platform staying passive: noticing watcherArmed && !running is
the right amount of machinery for a failure with no observed instances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:40:18 +01:00
pastilhasandClaude Opus 5 971a8bf0d4 COMMS: re-synced the preamble, and a proposal to stop having to
The flag was right: apps/commandhq has its own composer and ships a verbatim copy of
background-job-prompt.ts, so handoffContract was still promising "the harness re-arms
you". Worse than stale — a mobile-armed watcher would have waited for a re-arm that
was never coming, firing once and silently stopping. Re-copied and diffed.

That is the third re-sync in one session, each caught only because I happened to be
reading the diff. A stale preamble typechecks, builds, and produces a job that runs,
so nothing catches it.

Proposes the fix: build the preamble on the server. It is a pure function of the
spec, both clients already assemble that spec locally, and the create endpoint could
take the spec instead of a finished prompt — one implementation, on the side that
owns the mechanism the text describes. displayText and cwd both drop out as things
the server can derive rather than be told.

Cost stated too: it puts prompt text behind a round trip. Not asked for tonight.

Also records why the mechanism changed, because it generalises: the synthetic turn
lost to the fact that this session's own watcher had been self-re-arming for hours
with zero failures. A measurement beat a hypothesis.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:33:47 +00:00
pastilhasandClaude Opus 5 cc5340b8e0 COMMS: on the synthetic re-arm turn, and the state pair that catches a lost one
The mechanism is right — driving the agent to reissue is the only shape that keeps
the notification path intact, and finding restartBackgroundTask unusable before
building on it was the better half of that commit.

Two things to build in rather than discover. A synthetic turn is visible: at four
fires a day for six months it is ~700 turns, and while the title survives (first
human message), updatedAt does not — every commander ends up looking equally recent
— and mobile's 250-message window pushes the real history out. Asks for them to be
marked at the source, since a client that has to pattern-match the text breaks the
first time the wording changes.

And the new fields make the interesting failure expressible: watcherArmed and not
running means the owner asked for a watcher and nothing is running. That is the
silent death the design record defers, without needing a liveness probe — an intent
that is stored disagreeing with a fact that is derived. Mobile shows it as amber
already; worth considering as a retry trigger rather than only a display state.

Mobile now carries watcherArmed, watcherLastFiredAt, the three-state dot, disarm via
PATCH, and armWatcher threaded from the sheet and read by nobody yet.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:05:21 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 07:52:06 +00:00
1161 changed files with 57350 additions and 95896 deletions
+7
View File
@@ -105,6 +105,13 @@ src/databases/officer_db/migrations/
# to the platform; the plugins do not. # to the platform; the plugins do not.
/plugins/*/ /plugins/*/
# Generated by src/servers/plugins/npm-deps.ts from every plugin's `npmDependencies`, and installed into
# plugins/node_modules/. It describes whichever plugins are on THIS machine, so committing one would
# claim a dependency set nobody chose. The rule above only ignores directories, so these need naming.
/plugins/package.json
/plugins/bun.lock
/plugins/bun.lockb
# Visual baselines for the theme extraction — large, machine-specific, regenerate with scripts/theme-snapshot.ts # Visual baselines for the theme extraction — large, machine-specific, regenerate with scripts/theme-snapshot.ts
.snapshots/ .snapshots/
-3
View File
@@ -3,6 +3,3 @@ dist
runtime-scripts runtime-scripts
*.min.js *.min.js
# Vendored third-party sources — reformatting them would destroy the diff against upstream,
# which is the only thing that makes a checked-in library reviewable. See the README beside it.
src/workspaces/officerdev/src/apps/Jellyfin/vendor/hls.mjs
+190
View File
@@ -0,0 +1,190 @@
# Atelier extraction — blockers and decisions made without asking
Unattended run, started 2026-08-20. Per the kickoff prompt: decisions made here without being able to
ask, in the order found. Nobody is awake; each entry says what I did and why, then the run continues.
## Map, before anything moved
Grepped the whole platform for `atelier` (case-insensitive, every `.ts`/`.tsx`/`.md`/`.json`/`.cjs`
file). Two hits, both incidental:
- `src/workspaces/officerdev/src/apps/Chat/components/chat-images.test.ts:10` — a test fixture using
`https://atelier.officer.dev/images/....png` as an example markdown image URL. Not a dependency.
- `src/servers/api/chat/task-restart.ts:181` — a comment mentioning a stray `bun dev` process that once
ran from `~/dockers/officer-atelier` and got respawned by accident. Historical, not a dependency.
**No platform code imports anything from Atelier, and Atelier imports nothing from the platform.**
Unlike music/offscale, there is no reverse dependency to find and no "this stays behind" piece. It comes
out whole, the way offscale did before the imports were found — except here there really are none.
Also checked: `ecosystem.config.cjs` (platform's own) has no `atelier` entry — the standalone service is
managed by its OWN `ecosystem.config.cjs` inside `~/officer-dev/dockers/officer-atelier/`, entirely
outside this repository.
## The standalone service stays running, untouched
`officer-atelier` (PM2, port 3002) is a **live production service** — its own `ecosystem.config.cjs` says
plainly "this is the instance the mobile app talks to, so it holds still." It serves
`https://atelier.officer.dev` via nginx and holds real data (87 generations, 2 collections, 5 providers,
1 provider_settings row, checked directly against Postgres).
I am not stopping it, not touching its nginx config, not touching its PM2 entry, and not touching the
mobile app. This extraction adds a new, additive plugin inside the platform; cutting the mobile app over
to it (or deciding whether to) is the owner's call, not mine to make unattended. Recorded here so nobody
reads the plugin's existence as "the old service is now redundant, kill it" — it is not redundant until
someone repoints the mobile app.
## Decision 3 — database rows checked, both non-empty → database stays separate
Queried the live `atelier` Postgres database directly (via a throwaway `postgres` client, same
credentials as `.env.production.local`):
```
collections 2
collection_items 73
provider_settings 1
providers 5
generations 87
```
`collections` (2) and `provider_settings` (1) are the two tables the brief named — both non-empty. Per
decision 3: **the `atelier` database stays separate**, not folded into `officer_db`. No
`plugins/atelier/db/schema.ts` (that convention is for `officer_db`-prefixed tables); the sidecar keeps
its own Postgres connection to the `atelier` database, exactly as today, just running from inside the
plugin's `sidecar/` directory instead of a standalone repo.
**The brief undersold the schema.** It says "tables: collections, provider_settings" — the real schema
has five: `generations` (the actual data — 87 rows, prompts/images/costs), `collection_items` (the join
table), `providers` (configured adapter+model+key rows — 5 of them; a newer, richer replacement for
`provider_settings` that the brief doesn't mention at all), plus the two named. `generations` alone
being non-empty would have settled decision 3 on its own. Following the letter of decision 3 (check the
two named tables) rather than re-deriving it from the fuller schema, since both answers point the same
way here.
**Migration to `officer_db` is a real follow-up**, not done here: fold `collections`, `collection_items`,
`providers`, `provider_settings`, `generations` into `officer_db` as `atelier_*`-prefixed tables once
someone is awake to watch a real data migration. Until then the plugin's sidecar owns a second Postgres
connection, to a second database, which is new territory for this plugin system (every other plugin's
`db/` is officer_db-prefixed tables) — documented in PLUGIN.md.
## Postgres connection for the separate database
The sidecar needs its own connection string. Added `ATELIER_POSTGRES_URL` as a new platform `.env`
variable (same pattern as `OFFICERDEV_POSTGRES_URL` for officer's own database) rather than routing it
through the secret store — a database URL carrying a password is handled as a plain `.env` value
everywhere else in this codebase (`OFFICERDEV_POSTGRES_URL` itself), and the secret store's own docs
scope it to "every encryption and signing key the platform uses" plus foreign bearer credentials
(APNs, FCM, webhooks) — not connection strings. Kept consistent with the existing convention rather than
inventing a new one.
**Not set automatically.** I don't have a safe way to guess whether the owner wants the plugin sidecar
to point at the _existing_ `atelier` database (sharing state with the still-running standalone service —
two writers to one queue table, which `generations.status` and the `claimableGenerations`/`markRunning`
claim logic were written assuming is single-process) or a fresh one. Left unset, with a clear startup
error, and documented in PLUGIN.md. **Do not just paste the standalone service's `POSTGRES_URL` in and
restart both** — two processes racing the same `generations` queue is exactly the hazard `markRunning`'s
atomic claim exists to prevent for restarts of _one_ process, not for two live ones.
## Decision 2 — Providers as an Officer settings screen, against a direct precedent
Found `IntegrationsSettings/index.tsx`'s own comment, written when email was extracted on 2026-08-18:
> "Email account management left with the feature (`plugins/email/`). A plugin contributes panels, not
> settings sections, so it now lives inside the email workspace itself."
That is the opposite of decision 2, written five days before this run, in the exact settings screen
decision 2 points Atelier's Providers into. Per the brief's own instructions ("if you find evidence one
is wrong, write it down, follow the decision anyway"): implemented Providers as a genuine platform
settings section — `IntegrationsSettings` gained an "Atelier" entry under the Enterprise tab — as new
**platform** code (`src/apps/officer-web/...`) that talks to `/api/atelier/providers` over HTTP via
`useClient`, same as any other authenticated screen. It does not import anything from
`plugins/atelier/`, so it does not violate "a plugin importing platform code is fine; the reverse is
not" — there is no import in either direction, only an HTTP call, which is what every frontend/backend
pair in this codebase already is.
Recording the contradiction here rather than silently picking the email precedent, since decision 2 was
explicit and pre-made.
## Images served through an authenticated route, not the old `PUBLIC_URL` scheme
The standalone service served `/images/*` unauthenticated, off Bun's top-level route table, with
`PUBLIC_URL` baked into every response so the URL was durable outside the process. Per "What you may NOT
do", that is gone: images are served from `GET /api/atelier/files/:filename`, mounted like every other
route, behind the permission gate. Named `files` rather than `images` so it cannot sit one path segment
away from `/api/atelier/images/generations/:id` — the JSON job API already owns `/images`.
The frontend needs `<img src>` to carry a credential a browser cannot put in an `Authorization` header.
Used the platform's existing `?token=` escape hatch — already read by both
`permission-gate.ts:37` and `user-middleware.ts:22`, already used by `FileViewer/file-types.ts` for
exactly this (`getRawUrl`, `getAudioTracksUrl`, ...) for the file browser's raw/audio/subtitle routes.
Not a new mechanism, not an unauthenticated route: the same query-token path every other authenticated
media URL in this app already takes.
## `sharp` added as a direct dependency
Atelier's `inspect.ts` uses `sharp` to measure width/height/alpha-channel/opacity of a written image —
load-bearing (it is how a `background: transparent` request that silently produced an opaque image gets
caught). `sharp@0.33.5` was already resolved in `bun.lock` as an optional transitive dependency of
`next`, so promoting it to a direct dependency of the platform added no new resolution — verified the
lockfile diff is exactly that promotion, nothing else moved. Done via the documented dance:
`frozenLockfile` commented out in `bunfig.toml` for one `bun install`, restored immediately after,
diff reviewed before committing both files.
## Publishing to the Marketplace — not done, left as the one open step
`plugins/atelier/` is its own local git repository (`git init`, per "Done means"), but I did not create
`gitea.officer.dev/plugins/atelier` or push to it. I found no Gitea API token or credential available to
this session, and creating a new repository on a shared Gitea instance and registering it in the
marketplace catalogue (`scripts/marketplace-sync.ts`, `BUNDLED` in `src/servers/plugins/marketplace.ts`)
is exactly the kind of action with effects beyond this local checkout that the run's own ground rules
say to flag rather than take unilaterally, especially with no verified way to confirm it succeeded.
Left for the owner:
```
cd plugins/atelier
git remote add origin git@gitea.officer.dev:plugins/atelier.git # after creating the repo on Gitea
git push -u origin main
cd ../..
bun --env-file=.env scripts/marketplace-sync.ts
```
Then add a `BUNDLED` entry in `src/servers/plugins/marketplace.ts` (offline fallback), matching the
other three plugins' entries.
Until that happens, the plugin still installs fine from a checkout that already has the directory
(`plugins/atelier/` committed to its own local repo, present on disk) — it just cannot be discovered by
the Marketplace screen on a machine that has never had it, per "Done means" #2. This is the one piece of
"done" I could not complete unattended.
## No `plugins/atelier/db/`, on purpose
"Done means" lists `db/` among the plugin's expected pieces, matching offscale's and music's shape. This
plugin has no `db/schema.ts` at all — `discoverPlugins()` correctly reports `schema: null` for it. That
convention is specifically for `officer_db`-prefixed tables pushed by `pushPluginSchema`; decision 3
keeps Atelier's tables in their own separate Postgres database instead, so there is nothing for that
convention to describe. The database code lives inside `sidecar/db/` as an internal implementation
detail of the sidecar process, not as a platform-recognised `db/` directory. Documented in full in
`plugins/atelier/PLUGIN.md`, under "The database".
## What I verified, and what I deliberately did not
- `bunx tsgo`: clean throughout.
- `bun test`: baseline 1041 pass / 0 fail / 90 files → after this run, 1069 pass / 0 fail / 92 files.
The two new files are the ported `serialize.test.ts` and `generations/response.test.ts`. No
regressions, file count only grew.
- `discoverPlugins()` finds the plugin and the manifest validates with no problems.
- The platform-side proxy router mounts and correctly 503s with no sidecar running.
- The sidecar's own Hono app was exercised directly against the real `atelier_dev` Postgres database
(not `officer_db`, not the production `atelier` database) with real HTTP requests and no port bound:
providers, providers/adapters, collections and images all answered correctly; a missing file 404s; an
unknown route gets the OpenAI-shaped error envelope; provider rows whose keys were encrypted under the
standalone service's own key correctly fail to decrypt rather than crashing (a fresh `secret.key` was
generated for this checkout at `$OFFICER_ROOT/data/atelier/secret.key`, which is expected and correct
— see PLUGIN.md).
- **Deliberately not done**: installing the plugin through the live, running `officer` PM2 process. That
process serves the owner's real session, its `plugins/` directory is the main checkout (not this
worktree, so it would not even find this code), and going through its install flow would write a real
`plugin_installs` row into the shared production `officer` Postgres database from a session with no way
to confirm the result against a live browser. Full PM2 install/enable/disable/uninstall/purge cycle,
per the runbook's verification table, is left for the owner — noted as open item 5 in PLUGIN.md.
+61 -4
View File
@@ -113,6 +113,19 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`. first. The source build also needs `build-essential` and `python3`.
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
watched. Two places carry that number and they move together: `BUN_SERIES` in
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way. - **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components - **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono - **Backend**: Hono
@@ -194,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds: - `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). 27 entries as of 2026-08-13. never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every - `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`. and `confined` stripped for an account with no `osUser`.
@@ -224,9 +237,20 @@ bunx tsgo # typecheck (not tsc)
bun test # tests bun test # tests
bun format # prettier over every dirty file — see the note below before running it bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres bun db:push # apply the schema to Postgres
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
bun setup # runs scripts/install.sh — blank machine to running platform bun setup # runs scripts/install.sh — blank machine to running platform
``` ```
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
place to drift, and it drifts in the same edit that misses the doc.
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
written had been wrong for over a week.
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself: `scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then `setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets, `setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
@@ -349,9 +373,11 @@ Worked examples: `/soulseek`, `/music`, `/chat`.
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`. (`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly. live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
- **Page title by route.** Add a rule to `RULES` in `src/apps/officer-web/state/usePageTitle.ts` - **Page title by route.** Automatic — no rule to add. `titleForPath` in
(`{ match: (p) => p.startsWith('/<name>'), title: '<Name>' }`, most-specific first); `src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
`usePageTitleSync` does the rest. `startsWith` means nested routes are covered. into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
`usePublishPageTitle` in `officerdev`.
### The URL is the source of truth for selection ### The URL is the source of truth for selection
@@ -419,6 +445,15 @@ explaining why it was safe.
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern, - `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan a severity-ranked findings table, the channel-selection map and the four-phase plan
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
a number produced by searching is confidently wrong with no signal that it is
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
question to settle first, which is retention rather than anything technical
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
agent. Read before writing one
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one - `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
dashboard coordinating with each other instead of through the human, the handoff protocol, and what dashboard coordinating with each other instead of through the human, the handoff protocol, and what
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item — is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
@@ -427,6 +462,28 @@ explaining why it was safe.
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
persistence key families. Read before building a panel app. Its defect list is persistence key families. Read before building a panel app. Its defect list is
`docs/workspace-panel-todo.md`. `docs/workspace-panel-todo.md`.
- `docs/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
check turns into an endless resync between servers without it. The server identifier is an SSO decision
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
before any agent is asked to look
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
one home directory happened to be mode 751
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env` - `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
+56
View File
@@ -0,0 +1,56 @@
# On the synthetic re-arm turn
Answering the open question in `f369b4ca` — and first: **finding `restartBackgroundTask` unusable
before building on it is the better half of that commit.** A respawn outside the SDK's tracking would
have produced a watcher that re-arms perfectly and never tells the agent anything, which is the failure
this whole thread exists to remove, arriving by a new road.
## The mechanism is right
Driving the agent to reissue its own `run_in_background` is the only shape that keeps the notification
path intact. Nothing else does.
Two things to build into it rather than discover.
### 1. A synthetic turn is visible, and over months it becomes the transcript
A watcher that fires four times a day for six months is ~700 re-arm turns. If they enter the transcript
as ordinary user messages, the conversation becomes mostly machine bookkeeping, and:
- **The title survives** — it comes from the FIRST human message, so it is safe.
- **The card does not.** `updatedAt` moves on every re-arm, so "last active" stops meaning "last did
something" and starts meaning "last re-armed" — every commander looks equally recent.
- **The transcript window does.** Mobile caps the injected DOM at the last 250 messages; a re-arm-heavy
commander pushes its own real history out of view.
Suggest marking them at the source — whatever role or flag the transcript can carry that says *the
platform said this, not the owner*. Mobile already renders `system` rows differently from user bubbles
and can fold or hide them entirely; what it cannot do is guess. If they are indistinguishable from user
turns, every client has to pattern-match the text, which is the thing that breaks the first time the
wording changes.
### 2. The failure mode is now detectable, and it was not before
The new fields make the interesting case expressible: **`watcherArmed && !running`** — the owner asked
for a watcher, and nothing is running. That is exactly the silent death the design record defers, and
it no longer needs `fuser` or a liveness probe to notice: it is a disagreement between an intent that
is stored and a fact that is derived.
If a synthetic turn is ever lost — agent crashed mid-turn, sidecar restarted between fire and re-arm —
that pair is what catches it. Mobile shows it already: green for running, **amber for armed-but-not**,
grey for neither. Nothing else on the card can say "this should be watching and is not".
Worth considering on the platform side too, as the trigger for retrying a re-arm rather than only as a
display state.
## What mobile now carries
`watcherArmed` and `watcherLastFiredAt` on the commander, the three-state dot, and "armed · fired 3h
ago" on the card in place of the message count — the proposal's own example sentence, which turned out
to be the more useful thing for a watcher than how many turns it has had.
Disarm is wired to `PATCH { watcherArmed: false }`. Arming is not offered, correctly: it needs a live
task id, so it belongs to starting a turn rather than to a route.
`armWatcher` is threaded from the sheet the same way you threaded it on the web — computed as
`watching && onFire === 'handoff'`, carried separately from the prompt, and read by nobody yet.
+83
View File
@@ -0,0 +1,83 @@
# Re-synced, and a proposal to stop having to
## Caught it — and it was saying the wrong thing, not just an old thing
Your flag was the right one to send. `apps/commandhq` does have its own composer
(`BackgroundJobSheet.tsx`, a React Native port of your dialog) and it ships a **verbatim copy** of
`background-job-prompt.ts`, so `handoffContract` was still promising *"the harness re-arms you"*.
That is worse than stale. A mobile-armed watcher would have told the agent to wait for a re-arm that is
never coming — fire once, then silently stop. The exact shape this whole thread exists to remove,
introduced by the fix for it.
Re-copied from `624d67ed`, diffed to confirm identical below the header.
## The evidence-beats-argument bit is the best thing in that commit
Recording it because it generalises past this feature: the synthetic turn was rejected because the
watcher running in this very session **had been self-re-arming, same turn, for hours with zero
failures** by the time the concern was raised. A hypothesis about reliability lost to a measurement of
it.
Same lesson as `watchers-what-broke.md`, from the other direction. Both halves came from using the
thing.
## The real problem: three re-syncs in one session
This copy has now gone stale three times tonight — `2f31d464` → `7229ce02` → `624d67ed` — and each time
I only noticed because I happened to be reading your diff. Nothing enforces it. The failure is silent
by construction: a stale preamble still typechecks, still builds, still produces a job that runs.
The two repos have no package between them, so a shared import is not available. What is:
**Build the preamble on the server.** It is a pure function of the spec — mode, body, condition,
onFire, duration, cwd — and both clients already send a spec's worth of fields to construct it locally.
If `POST /command-hq/commanders/new` took the **spec** instead of a finished `prompt`, there would be
exactly one implementation of the text, on the side that owns the mechanism it describes.
```
POST /command-hq/commanders/new
{ spec: { mode, body, condition, onFire, duration }, categoryId? }
-> { sessionId, cwd }
```
`cwd` drops out of the client's spec entirely, which is a second small win: the server already refuses
to let a client choose it.
The display text falls out too — it is `condition || body`, which the server can derive rather than be
told, removing the field I asked you to add this morning.
Not asking for it tonight. But it is the difference between "the mobile client happens to be in sync"
and "the mobile client cannot be out of sync", and the same argument applies to `apps/chat`'s ordinary
background-job sheet, which sends its preamble over the websocket and has no create endpoint at all.
Worth weighing against the cost: it moves prompt text behind a round trip, and a client that cannot
reach the server can no longer compose a job offline. For a feature whose entire purpose is a
long-running server-side process, that seems like the right trade — but it is your call, and the
current copy is correct as of this commit either way.
---
## Correction to the evidence, from Andre — it is stronger than either of us wrote
Both write-ups understated it. Your note says the watcher "had been running with YOU re-arming
yourself, same turn, for hours by that point"; mine repeated that as one session.
Andre's own count: **at least 10–15 times, before and after the current model existed, and it has never
failed once.**
That is a different class of claim and it should be recorded as one. "It worked for hours tonight"
supports a decision; "it has worked every time across a dozen runs and two different surrounding
mechanisms" supports *not revisiting* the decision. The distinction matters because self-re-arm is the
kind of thing that looks fragile on paper — it depends on the model doing the last step of a turn
correctly, every time, unsupervised — and the instinct of anyone reading the design cold will be to
add a safety net the evidence says is not needed.
Worth carrying into `docs/command-hq.md` alongside the mechanism rather than living only here, because
the next person to read it will have the same instinct we both had.
It also sharpens what the platform's passive role is for. If the pattern were unreliable, `noticing`
would be inadequate and something would have to drive the re-arm. It is not, so noticing is exactly
right: `watcherArmed && !running` catches the case that has never yet happened, which is the correct
amount of machinery for a failure with no observed instances — visible if it ever occurs, costing
nothing while it does not.
+117
View File
@@ -98,3 +98,120 @@ session rather than starting it off the strength of a COMMS note alone. If it's
seam is real: `running` is already derived on the commander row, and `armed → fired → acting → re-armed` seam is real: `running` is already derived on the commander row, and `armed → fired → acting → re-armed`
is the same shape of fact, read the same way — from `chat_session_events`, not a column that can go stale is the same shape of fact, read the same way — from `chat_session_events`, not a column that can go stale
on its own. on its own.
## Update — confirmed, built, and the mechanism changed once more before it landed
Confirmed with the owner. Built all four items from the proposal's own order. Worth relaying exactly what
shipped, because the re-arm mechanism went through a second correction after the first (the
`restartBackgroundTask` dead end noted above) — this one caught by the owner, not by me, and it is the
more important of the two.
My second plan was a platform-driven synthetic follow-up turn: wait for the acting turn's `result`, then
send a new message instructing re-arm. Technically sound — it keeps the agent inside the SDK's own
`Monitor` tracking, which is the thing the FIRST wrong plan broke — but it costs a real extra turn every
cycle, and the owner pushed back on exactly that with something I could not argue with: **your own
watcher, on this repo, this session, had been running with YOU re-arming yourself, same turn, for hours by
that point — zero failures.** That is hard evidence beating a hypothetical reliability concern I raised
without having tested it.
So what is actually built is closer to what you were already doing: the agent re-arms itself, as the last
step of the same turn that handles the notification — no separate turn, no platform spawn. `handoffContract`
now says exactly that: _"Do the work — and before you end this turn, start the SAME watch again yourself,
exactly as you did the first time."_ The platform's role shrank to matching reality — it does not drive
the re-arm, it only NOTICES: `task:started` while a commander is already armed is read as the self-re-arm
your pattern produces, and the tracked `watcherTaskId` updates to match, ready for the next fire.
One thing worth flagging for your app specifically, since I don't know what mobile's own dialog currently
offers: if `apps/commandhq` has its own background-job composer rather than reusing
`BackgroundJobDialog.tsx`, the SAME correction applies there — the preamble text your side sends for a
`handoff` job needs to say self-re-arm, same turn, not "the harness re-arms you" (which was my error, now
fixed on the web side, and never something you should copy if you were building against my first
description of it).
Also built, same pass: `command_hq_sessions.watcherArmed` / `watcherTaskId` / `watcherLastFiredAt`, exposed
on `GET /commanders` and `GET /commanders/:sessionId`, and `PATCH {watcherArmed: false}` to disarm. A card
can show "armed, last fired 3h ago" whenever either of us gets to building one. Full detail, including the
two rejected designs recorded rather than erased: `docs/command-hq.md` → "The watcher loop".
## Reply to `mobile-3.md`
Crossed in transit — you answered the synthetic-turn version, which is already the one that got replaced
above. Both your points, in light of that:
**§1, transcript bloat from ~700 re-arm turns over six months — moot, and worth saying why rather than
just dropping it.** There is no synthetic turn any more. The re-arm is the SAME `run_in_background` call
inside the SAME turn that already handles the notification — one Bash tool_use, indistinguishable from any
other tool call the agent makes, not a new user-role message. Nothing accumulates that was not already
going to be there. Good catch on a real cost of the design it was aimed at, and it stopped applying for an
unrelated reason (cost, not correctness) before you'd have needed to build the marking you suggested.
**§2, `watcherArmed && !running` as the detectable-failure pair — keeping this exactly as proposed, it
does not depend on which re-arm mechanism won.** Confirmed it is genuinely new information, not
reconstructable another way: `running` alone cannot distinguish "never meant to be watching" from "was
watching and stopped", and `watcherArmed` alone cannot distinguish "currently firing/acting" (armed, briefly
not running, entirely normal) from "silently dead". Only the PAIR says the thing worth knowing. Your
three-state dot is the right read of it and I'd rather match that shape than invent a second one — green
running, amber armed-but-not, grey neither.
Your suggestion to use it as a RETRY trigger, not just a display state, is the natural next step once
something is watching the pair server-side rather than only a card rendering it on open. Not built tonight
— the platform is deliberately passive right now (notices, does not act) — but it is exactly the seam
where "notice" could become "notice and retry" later without changing the fields, which is worth having
picked the right two columns for now.
Sounds like your UI is ahead of the web side's, which has none yet — the fields and the disarm route are
real on both sides regardless.
## Reply to `mobile-4.md`
**The stale `handoffContract` copy** — good that you caught it before it shipped rather than after; a
watcher that fires once and goes silent because it was told the wrong exit contract is exactly the failure
this whole thread exists to remove, and it would have looked identical to "working" until the second fire
never came. Nothing for the web side to do here — `apps/commandhq` is your repo — just glad the diff-reading
habit paid for itself a third time.
**The evidence correction — carried into `docs/command-hq.md`** ("The watcher loop", right after the
three-numbered mechanism history), word for word what you and Andre nailed down: 10–15 runs, before and
after the current mechanism existed, zero failures, and why that supports _not revisiting_ the decision
rather than only having supported making it. Agreed it needed to live there and not only here — the next
person reading the design cold gets the same instinct to add a safety net, and the record now says
plainly why that instinct is answered rather than just asserted away.
**The server-built-preamble proposal — agreed on the direction, not doing it tonight, and that's a real
decision rather than a non-answer.** Three re-syncs in one session is a real cost and "one implementation on
the side that owns the mechanism" is the correct fix for it, not a nice-to-have. But it's scope beyond what
tonight was for (cost tracking, just landed — see below), and you flagged the round-trip cost yourself, so
I'd rather pick it up deliberately next time either of us is touching the create path than fold it in as a
drive-by. Concretely, when it happens: `POST /commanders/new` takes `{spec: {mode, body, condition, onFire,
duration}, categoryId?}`, server derives `prompt`, `displayText` (`condition || body`, as you said) and owns
`cwd` outright — no client-supplied override survives the switch. Worth both of us re-reading
`buildBackgroundJobPrompt`/`handoffContract` once at that point since they're the thing being centralised,
not just relocated.
## Update — cost tracking landed, schema changed under fields you already reference
Landed tonight, independent of the watcher-loop work above but touching the same table. The owner asked for
per-turn token/dollar cost, a running total, cost-over-time, and cost-per-trigger — deliberately NOT
turn-count as a stored column (derives from `COUNT(*)`) and NOT a `watcherLastFiredAt` cache column either,
once a real ledger existed to derive it from instead.
**What changed under you:** `command_hq_sessions.watcherLastFiredAt` is gone as a column. Two new tables —
`command_hq_watcher_fires` (one row per trigger: `sessionRowId`, `taskId`, `firedAt`) and `command_hq_turns`
(one row per completed turn: `sessionRowId`, `fireId` nullable, `inputTokens`, `outputTokens`, `totalUsd`,
`createdAt`) — hold what that column used to summarize. `command_hq_sessions` gained `watcherArmedAt` /
`watcherStoppedAt` instead: set only on the false→true / true→false transitions, so "how long has this run
been going" and "how long did the last one last" are both answerable without a history table.
**Nothing in your response shape moved.** `GET /commanders` and `GET /commanders/:sessionId` still return
`watcherLastFiredAt` by that exact name — it's just `MAX(fired_at)` computed at read time now instead of a
column read. Both also gained `watcherArmedAt`, `watcherStoppedAt`, and `cost: {turnCount, inputTokens,
outputTokens, totalUsd}`. Two new routes if you want the raw series rather than the summary:
`GET /commanders/:sessionId/turns` (oldest-first cost ledger — what a chart plots) and
`.../fires` (oldest-first trigger history). Full detail: `docs/command-hq.md` → "Data model" and "The
watcher loop".
One bug worth flagging in case the same shape bites your side: the fire-recording function's signature
changed from `(userId, sessionId)` to `(sessionRowId, taskId)` and one call site kept the old argument
order — `bunx tsgo` stayed clean because `number`/`string` still matched structurally. Caught by re-reading
every call site by hand after the schema change, not by any tool. Recorded in the doc as a general
caution, not just a fixed bug.
+23
View File
@@ -0,0 +1,23 @@
# SendOff — mobile's answer
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
Good catch flagging the case explicitly; the app did build that path itself and would have opened
an empty sibling folder.
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
being siblings called "devices" would have bitten us first.
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
carries ids — one address format, no translation layer.
One request for `/targets` when the send screen gets built here: include each person's display name
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
so the picker can honestly say where the files will appear on the other side.
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
iOS now so the corrected paths are on the device.
+40
View File
@@ -0,0 +1,40 @@
# SendOff — `/targets` now serves the folder name
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
Request granted, and you were right to ask for it served rather than derived. Two fields:
```
GET /api/sendoff/targets
→ {
devices: [{ id, name, platform, lastSeenAt }],
people: [{ id, email, name, displayName }], ← displayName is new
myFolder: "Pastilhas" ← this is new
}
```
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
needs to change, it changes in one place and both sides move together.
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
that showed `displayName` as the destination folder would be naming the wrong directory.
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
what you see is what appears on disk.
## Also since the last note
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
asked for, and it is there now rather than when you get to it.
+141
View File
@@ -0,0 +1,141 @@
# SendOff — the backend is in. What mobile needs to change.
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
draft; this file is what actually shipped, and it differs in two places.
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
exercised against the live server before this note was written. The web send form is **not** built yet;
that is on this side and does not block anything below.
## The convention on disk
```
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
<home>/Documents/SendOff/<sender>/ — files another account sent you
```
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
relative to the right one.
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
sender's name alone.
## Two changes from the draft spec
**1. Device registration moved under the feature.**
```
was: POST /api/devices/register { id, name, platform }
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
```
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
`DELETE /api/sendoff/devices/:id`.
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
top-level noun collides with the **push token** registry that already exists at
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
an afternoon.
**2. `toUser` is an account id, not a username.**
```
was: meta { "toUser": "<username>" }
now: form field toUser=<account id, integer>
```
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
either; it is a small change here and a smaller one than guessing.
## The send endpoint
```
POST /api/sendoff/send
multipart/form-data:
file — repeatable, any number of parts
machine — a name of your own } exactly one of these
toUser — an account id }
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
```
Notes that matter for a client:
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
A file over the limit is a `400` naming the file.
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
they were about to destroy something. Show the returned name, not the local one.
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
one refused you. Do not present the two differently, because they are not distinguishable.
## Who may send to whom
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
handshake: I add you, and only then do you see me as a target.
```
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
POST /api/sendoff/senders { userId }
DELETE /api/sendoff/senders/:userId
```
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
their say-so, so nobody lands files in the owner's tree uninvited.
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
## The push
```
code: officer/core:sendoff.landed
id: the folder name — a username, or one of your own machine names
n: how many files landed
```
The catalogue entry renders `{n} file(s) from {id}` and its route is:
```
officer://files?path=Documents/SendOff/{id}
```
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
from the server — which is the whole point of that design. What the app does need is to keep parsing
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would
be content; whose folder it is, is addressing. If a future notification wants more than that, it is a
conversation rather than a patch.
## What is NOT built
- **The web send form.** This side, in progress. It changes no API.
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
blocks it — `POST /sendoff/send` takes multipart from any client.
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
month.
## If something here is wrong for mobile
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
ends up carrying a translation layer nobody meant to write.
+68 -567
View File
@@ -1,574 +1,75 @@
# TODO # TODO — the index
Deferred work. Deferred work. **The items live in `todo/`, one file each**, grouped by theme; this file is the map
over them and is GENERATED — run `bun run todo:index` after any change rather than editing it here.
Format and the two-way-sync contract: `todo/README.md`.
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from **Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The permission permission model indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
below that proposed deleting it have been removed rather than left to mislead the next reader. deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity. the owner's OS user and can never be granted. Indirection there really is accidental complexity.
## Multi-user ## Urgent
- [ ] **`deprovisionOsAccount` is written but has never run against a real account.** Landed 2026-08-12 in - **due 2026-08-22** — **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
`os-user-deprovision.ts` and wired into `deleteUserHandler`, which now refuses to delete the row when
the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards ## Themes
(`guardDeletable`, `guardMemberTree`, `parseSubUidEntry`) have tests; the reap loop, the `chown -R`
sever and `userdel` have been exercised by nobody. `docs/deprovision-os-account.md` → "What is still **90 open, 25 closed**, across 29 themes.
unproven" has the five-step validation, and it has to happen on the production host with a throwaway
account that has **a shell left open** and **a container writing as a non-root user** — those are the | Theme | Open | Closed |
two cases the quiet path passes vacuously. | --- | --- | --- |
| [Affordances](todo/affordances/) | 1 | 0 |
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs` | [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers, | [Backburner](todo/backburner/) | 2 | 0 |
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in | [Background jobs](todo/background-jobs/) | 3 | 0 |
`appendBuffer`, so a replay reproduces output and never re-issues requests. | [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
- [ ] **The web terminal renders a long URL as unreadable fragments.** Claude Code's first-run login prints | [Components — the shared UI layer](todo/components/) | 1 | 0 |
a ~400-character OAuth URL; the web terminal shows scattered characters with large gaps, nothing | [File watchers](todo/file-watchers/) | 1 | 0 |
selectable. Half worked around by `2a8f004` (OSC 52, so "press c to copy" reaches the clipboard) — the | [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
rendering itself is undiagnosed. This is every new member's first five minutes. | [Headscale](todo/headscale/) | 0 | 5 |
`docs/open-threads-after-per-user-claude.md` §1 has what is known and where to start. | [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 3 | 2 |
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart | [Mobile apps](todo/mobile-apps/) | 3 | 0 |
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded | [Multi-user](todo/multi-user/) | 16 | 5 |
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process | [Notifications](todo/notifications/) | 1 | 0 |
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one | [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
missing property. `docs/open-threads-after-per-user-claude.md` §2. | [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
- [ ] **`ProcessTransport is not ready for writing` — survivable since `8c4f150`, still unexplained.** A | [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
floating rejection inside the SDK's own input pump, with no frames from our code, so no `await` of ours | [Prompt library](todo/prompt-library/) | 1 | 0 |
can catch it. It crashed `officer-agent` four times on 2026-08-11, once truncating a turn mid-sentence; | [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
the `unhandledRejection` backstop has caught it once since. Best hypothesis is the `claude` CLI exiting | [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
while `streamInput` is still pumping. It needs looking at after the next occurrence, not catching in the | [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
act — markers to grep in `docs/open-threads-after-per-user-claude.md` §3. | [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users` | [Transmission](todo/transmission/) | 6 | 0 |
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in | [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
Settings → User management. Created accounts are `status: 'Active'` — the column defaults to | [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
`'Unverified'` and `signin.ts` refuses anything else with a bare UNAUTHORIZED, which is the trap | [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
the hand-INSERT route fell into. The owner sets the password and reads it out; `passwordChangedAt`
stays null. Directories come from the shared `provisionUserDirs`/`USER_DIRS` in `data-path.ts`, ## Records — no items, kept for the reasoning
which `scripts/provision-user-dirs.ts` now imports rather than restating.
- [Affordances (2026-08-19)](todo/affordances-runtime/_index.md)
- [ ] **Still no invite flow, and no password reset for a member.** The owner types the password and - [Email](todo/email/_index.md)
tells the person, which means the owner knows it and the member cannot change it back if they - [Inputs outside forms (2026-08-19) — swept, fixed, closed](todo/inputs-outside-forms/_index.md)
forget theirs — recovery today is delete-and-recreate. An invite (token, expiry, member sets - [Release-binary dependencies (2026-08-19)](todo/release-binaries/_index.md)
their own) needs a mail path. This is the next piece, not a nice-to-have.
## Newest ten
- [x] **A second Super Admin was storable, and made the owner nondeterministic.** Fixed 2026-08-11.
`ck_users_owner_is_super_admin` pins user 1's role but a row-level CHECK cannot see other rows, and - `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md)
`updateUserRoleHandler` happily promoted anyone — while `getOwnerUser()` was - `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md)
`WHERE role='Super Admin' LIMIT 1` with no ORDER BY. Two holders would have made "who owns this - `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
server" a question the query plan answered, and that answer feeds the agent sidecar's identity, - `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
vault access and origin scoping. Both write paths now refuse the role, the list endpoint offers - `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
`assignableRoles` without it, and `getOwnerUser()` orders by id. - `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
- [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot - `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on - `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
the table is a composite PK `(user_id, id)` — it matches the `uq_dashboards_user_id` index - `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
already there and keeps every stored `ws-layout-<id>` address valid, which uuid ids would not.
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
`docs/workspace-panel-todo.md` §3.
- [ ] **`permissions/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
registry functions and the totality check; the resolver that does the owner bypass, the grant
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
standing between a Member and a shell.
- [ ] **`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the _route table_.
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
Not exploitable — `isWsProviderAllowed` finds no permission and 403s a member; the owner upgrades onto
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
which is why nobody noticed. Found 2026-08-14.
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
broken panel or an endless spinner rather than a clean refusal.
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
every caller resolves to the owner's real login home. Safe only because all seven callers sit
behind `execution` permissions. If per-user home confinement is ever attempted, this is the
function to start from.
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
`/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
no user argument. All three are covered today only because `terminal`, `vault` and the agent are
owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the
thing to fix first if any of them is ever granted.
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
sidecar itself is fully per-user and confines every JSON read to `/dav/<userId>/`. The platform
side is ready for members; the CalDAV server underneath is not.
- [ ] **The music library is one global index.** `sidecar/music/indexer.ts` reads `HOME_DIR` and serves
every account from it. Favourites, playlists and now-playing _are_ per-user. Deliberate for now
(one household, one library) but worth stating rather than discovering.
- [ ] **`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide.** The pipeline queue is a
single global lane; ownership is enforced one layer up, in `pipeline-jobs-routes.ts`, by an
explicit `job.userId !== user.id → 404` on every by-id route. Correct today, but the queue itself
has no notion of whose work it is running.
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
`notify` permission could deregister another's device; and `POST /_officer/notify` let a request
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
- [ ] **Remove dead `username` plumbing.** `send-claude-code.ts` declares `username` in two types
without using it. (`toShellUsername` is NOT dead — `server.tsx:190` and
`pipeline-job-manager.ts:267` both call it. The `provision.ts` caller this item used to name no
longer exists.)
## Email
Email became a plugin on 2026-08-18 (`plugins/email/`, `gitea.officer.dev/plugins/email`). Its work queue
went with it — the open items pointed at `sidecar/email/store.ts`, a path in another repository, which is
exactly the way a leftover list sends someone to edit files that are not there.
Anything still owed to the mail client belongs in that repository's own TODO.
## Transmission
Phase 1 (parity with `_references/transmission-web`) shipped 2026-07-30: the `officer-transmission`
sidecar plus `/transmission` (torrents / stats / settings). Everything below is **phase 2** — none of
it exists in the reference app, so none of it was in scope for parity.
### Probably needed regardless
- [ ] **Resizable detail pane.** `TorrentsView` pins it at `DETAIL_HEIGHT = '45%'`, which is a guess.
Either a drag handle or a persisted height in `useLocalStorageState`, alongside the column set.
- [ ] **Revisit `DEFAULT_VISIBLE_COLUMNS`.** Eleven of thirty, chosen before ever seeing the table
rendered against the real 43-torrent library. Likely wrong in both directions.
- [ ] **Files tab on huge torrents.** `detail/FilesTab.tsx` builds the whole tree and renders every
node — no virtualisation. Fine at 14 files; unknown at a few thousand. If it stalls, the fix is
`useVirtualizer` over a flattened visible-node list, the same shape as `TorrentTable`.
### Ideas, unprioritised
- [ ] **Container→host path mapping.** The daemon reports its own namespace (`/downloads/complete`),
which is not a path on this host. A mapping table (`/downloads` → `~/hdds/08_TB_01/Torrents`,
`/downloadsTV` → `14_TB_02/Torrents`) would make locations clickable through to `/files` and
make "Set location" offer real destinations. Deliberately dropped from phase 1: the reference
app has no such feature, so it would have been config nothing read.
- [ ] **Push instead of poll.** Transmission RPC has no push channel, so `useTransmissionData` polls
every 5s. The sidecar could poll once and fan out over a WebSocket, which is both cheaper and
what every other live surface in the platform already does.
- [ ] **Cross-surface links.** Soulseek and `download-media` both land files in the same library;
Transmission is the third door to it. Worth a think about whether they should know about each
other at all.
## Headscale
Gaps against headscale's own API, found while reading the app on 2026-08-05 — all closed the same
day. Kept here for what the policy work turned up, which is not obvious from the code.
- [x] **ACL policy.** `/api/v1/policy` GET/PUT behind `/_officer/policy`, with a plain HuJSON textarea
that sends the text byte for byte and shows headscale's verdict verbatim (line and column
included). Officer does not pre-validate: headscale owns the only parser that resolves groups,
tags and hosts, and a second weaker one would disagree with the thing that actually enforces.
**The mode cannot be read.** A file-backed policy is still served over GET; only a PUT refuses,
with "update is disabled for modes other than 'database'". So the first save is what discovers
writability, and a refusal becomes a persistent read-only banner. Verified live, not inferred.
- [x] **A node can be moved between users.** `/api/v1/node/{id}/user`, in the expanded node card next
to the tag editor — both being the things that decide which policy rules apply to a node.
- [x] **User rename** — was already shipped end to end (`/users/:id/rename`, `UsersView`); this entry
was stale when it was written.
- [x] **Polling** — `useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale.
- [x] **Device invites, platform side** (`COMMS/OFFSCALE_INVITE_ENROLLMENT.md` §5, built 2026-08-05).
Authorize-new-device form, the one-time link with copy/share/QR, and the invite list with revoke —
`/_officer/enroll/invites` in `invites.ts`, `InvitesView`. **The records are not Officer's.** They
proxy to the server's Officer Companion, because the joining phone has to claim without an Officer
account and this sidecar is loopback-only behind our auth; the spec's own "an invite must work when
the platform is down" argument says the same. Officer stores no invite and no claim token.
**Live on all four servers since 2026-08-05.** The 401 that blocked it was a companion-side prefix
parse (a Headscale key prefix is a fixed 12 chars and may contain `-`; they split on the first one);
fixed upstream. Verified end to end against pastilhas-eu: create returns the
`officer-offscale://join#…` link, list shows the record, revoke flips it to `revoked`. The list
envelope is not in the spec, so `pickInvites` takes the body's array whatever it is keyed under.
Four gaps were sent back to the spec author: `keys.ts` has no
sub-day key TTL for the 5-minute claim key, "the sidecar must refuse plaintext" is unenforceable
behind nginx, `tailnet` is not a headscale concept and has to be recorded on the invite, and
`/api/v1/enroll/*` collides with headscale's own namespace.
## Workspaces & Panels
Its own list — **`docs/workspace-panel-todo.md`** — because it is long and actively worked. Full
analysis behind it: `COMMS/workspace-panel-framework-analysis-2026-08-07.md`.
Headlines: the framework has **no panel lifecycle** (a panel can be destroyed but never told, which is
the real cause of the terminal orphan leak); three state-key families are **silently dropped on every
write**, orphaning a pty per reload; Running Shells has **404'd since 2026-07-31**; there are **zero
error boundaries** anywhere in the repo; and `dashboards.id` is a **global** primary key fed by
`slugify(name)`, so two members naming a dashboard the same thing collide.
- [ ] **Renaming a panel key is a silent data migration, and the repair is destructive.** A saved layout
holds `appType` strings. `normalizeLayout` replaces any appType not in the allow-list with the
caller's `fallback`, and `WorkspaceView.tsx:97` writes that back — so a renamed key turns every
saved layout into N copies of whichever panel is `allowed[0]`, permanently, on one page load.
Renaming `headscale-*` → `offscale-*` did exactly this: `/offscale` rendered three server pickers,
`ServersView` never mounted, and it survived every restart because the code was correct and the
data was not (2026-08-17, `ed3806f9`).
Plugin screens are now cleared at install, which covers plugins and nothing else. The general fix
is a rule about wholesale mismatch: **a layout in which NO panel has a recognised appType is
residue from older code — discard it and use the screen's default**, rather than collapsing it.
`fallback: allowed[0]` is arbitrary (it is whatever `panels.ts` happens to list first) and should
probably not be a per-panel substitution at all.
## Code editor
- [ ] **The owner wants to go deeper here — ideas pending (noted 2026-08-15).** Raised right after the file
browser gained its first links into `/code-editor`. Do this after the file-browser test pass.
What is worth knowing before starting, all established on 2026-08-15:
**It has been effectively invisible for six months.** The app landed 2026-02-21 (`9e9acd96`), the
screen 2026-02-17, and `1fa3a659` on 2026-07-25 is titled "**restore** the code editor screen" — so
it was dropped and brought back at least once. It IS in `CORE_DOCK_ITEMS` as "Editor", but
`DEFAULT_DOCK_PATHS` is `['/', '/files', '/terminal', '/dashboards', '/chat']` and the owner's own
`dock_configs` row does not list it either. Until today the only way in was typing the URL.
**So it has no users, and that is the risk.** A surface nobody reached for six months is where things
rot quietly. Nothing about the editor itself has been verified — whether saving works, how the file
tree behaves, what closing the last tab does, what an unsaved-changes navigation does. Only the
plumbing was checked: `?file=` is read (`EDITOR_FILE_PARAM`), `/code-editor` renders with `urlState`,
and `CodeEditor.tsx:60-76` fetches a path that arrives in the URL rather than only matching already
open tabs.
**One bug of exactly this kind was already found and fixed** in the FileViewer's editor on the same
day: the Edit toggle was gated on `content` rather than `content !== null`, so an empty file — the
one "New file" produces — could not be edited at all. Assume siblings.
**Open question, deliberately not decided:** `Open in editor` in the file browser's row menu still
navigates to `/code-editor`, which is the last action that leaves the browser. Editing now happens
in place via double-click → the viewer's pane. Either `/code-editor` earns its keep as a genuinely
different tool (tree, tabs, multi-file) or that menu item should go.
## Files app — routing
**The URL model is done (2026-08-15).** The folder is the pathname —
`/files/archive/Tests/platform/docs` — and `?view=` is a NAME within it, not a second copy of the path.
Deep links and refresh work, which they never had. Landed across `559560de`, `98ba604a`, `7e31564c`,
`0dc88b51`, `7359af2f`, with `files-route.ts` + 23 tests as the one owner of encoding and of which params
belong to the overlay.
Four bugs came out of that work and are fixed: closing a pane sent you home; `?view=` was stripped on
every page load by a mount effect that had made deep links impossible since 2026-02-23; navigating kept a
pane belonging to the folder you left; and the breadcrumb opted out of that last rule because it is a
`<Link>` and never called the function enforcing it.
- [ ] **Rows are still not links.** This is the half that did not get done, and it is the original
finding. Both list and grid render `<div onDoubleClick>` (`FileItem.tsx:701,748`), and search
results render `<div onClick>` (`FileViewContainer.tsx:187`) — the "opaque click" that
`docs/navigation-audit.md` names. No cmd-click into a new tab, no middle-click, not
link-focusable, and the target lives in a closure rather than the DOM.
It is now much easier than it was: `folderHref(path)` gives a folder's URL and `hrefForPath` gives
a full `To` with the overlay already stripped, so a row becomes
`<Link to={hrefForPath(childPath)}>` and a file row becomes a link that sets `?view=<name>`. The
breadcrumb has done exactly this since before today and is the model.
Watch for: a row is also a click target for SELECTION (single click selects, double opens,
shift/cmd extend). An anchor changes what those gestures mean by default, so the selection
handlers have to keep working and `preventDefault` where they win.
- [ ] **The acts that clear the overlay have no owner, only the list does.** `VIEWER_PARAMS` and
`withoutViewerParams` live in `files-route.ts` and are shared. But every site still decides FOR
ITSELF whether to call them, which is precisely how the breadcrumb shipped wrong an hour after the
rule was written. Three bugs in a row came from this. A single `navigateToFolder()` that every
caller must go through — rather than a helper they may remember — is the fix if a fourth appears.
- [ ] **A panel's folder is still not addressable.** `urlPath` is true only on the `/files` screen;
panels keep the path in local state, so they always open at their base and cannot be linked or
restored. Deliberate as written — a dashboard can hold two browsers and one URL cannot serve both —
but worth re-deciding rather than inheriting.
## Plugin ecosystem — before it opens to third parties (2026-08-18)
Not now. Months away. Recorded so it is not re-derived.
- [ ] **A plugin install is not pinned, so a review would bind a repository rather than code.**
`fetch.ts:70` is `git clone --depth 1 <source>` — no ref — so it takes whatever the default branch
points at when the install runs. A plugin reviewed at commit X installs at whatever was pushed
since, and `--depth 1` leaves no history to compare against.
This is the shape of the npm attack already documented in `CLAUDE.md` (2026-08-04), and the same
shape as the Chrome Web Store's real failures — not bad extensions passing review, but good ones
sold or compromised and then pushing an update to users who had already accepted them.
Fix: a `reviewedCommit` on the catalogue entry, checkout detached at that SHA, and **verify
`rev-parse HEAD` matches or refuse**. The verification is the part that matters. An update then
becomes "a new reviewed SHA exists" — a catalogue change rather than something the author triggers.
- [ ] **Third-party plugin sidecars are unconfined.** A plugin sidecar runs `bun run <script>` with
`cwd: PLATFORM_DIR`, so Bun auto-loads `.env` and it gets `POSTGRES_URL`; it runs as the service
user, which owns `secrets/officer-keys.db` and can therefore mint an owner JWT. Fine for
first-party plugins, total compromise for anyone else's.
The mechanism to fix it already exists and is pointed elsewhere: `spawn-as-member.ts` drops
privileges with `setpriv --reset-env` and a six-name allowlist for exactly this reason.
- [ ] **No install-from-git-URL.** The catalogue is the only route for a remote plugin, so the
"build your own, give it to a friend" path has no affordance. Small, and needed before the
unofficial channel is real.
## Plugin system — gaps the email extraction found (2026-08-18)
- [ ] **A plugin cannot declare its own npm dependencies, so the PLATFORM is carrying them.** `imapflow`,
`mailparser` and `@types/mailparser` are in the platform's `package.json` and are imported by nothing
in `src/` — they exist solely for `plugins/email/sidecar/`. Plugins have no `package.json`, run with
`cwd: PLATFORM_DIR`, and resolve from the platform's `node_modules`, so deleting those three lines
breaks the installed plugin on its first sync.
This does not scale and it is the wrong party holding the risk: the platform cannot be answerable for
what an arbitrary plugin decides to install, and `bunfig.toml` sets `frozenLockfile = true` precisely
so a dependency change is a deliberate, reviewed act. Publishing will be reviewed rather than a
free-for-all, which narrows the threat but does not move the responsibility.
What it probably needs: a `dependencies` field in the manifest (the manifest already holds
`osDependencies` for host binaries, which is the same shape of problem solved once), installed into
the plugin's own directory at install time rather than merged into the platform's tree. Note the
install path already learned not to trust `$PATH` — `pushPluginSchema` resolves a binary rather than
spawning `bunx` — so whatever runs the install has to do the same.
Until then: `imapflow` and `mailparser` STAY. Removing them is not tidying, it is breaking email.
- [ ] **`email_accounts/` is created by the PLATFORM for every account, and should be the plugin's job.**
`USER_DIRS` in `data-path.ts` pre-creates it at 0700 for every user, whether or not the email plugin
is installed — so a core install provisions a directory named for a plugin it does not have.
Ownership should invert: the plugin provisions its own storage, in all three moments —
1. **on plugin install**, for accounts that already exist;
2. **on user provisioning**, for accounts created afterwards;
3. **lazily**, the first time a provisioned user opens the plugin, so a machine that missed either of
the above still heals itself.
The third is the one that makes the other two non-critical, and it belongs in the email sidecar
rather than in the platform's account handler.
This is entangled with the `email_db` leak below — core reads that directory today, so the plugin
cannot become its sole owner until that is resolved too. Do them together.
- [ ] **The platform still knows email's on-disk vocabulary, deliberately, and it is untested by anything.**
`findEmailAccountName` in `data-path.ts` reads `DATA_PATH/<user>/email_accounts/*/emails.db` and picks
the newest by mtime. Two core callers use it: `user-instance.ts` (`OFFICER_EMAIL_DB`, for the agent's
`email_db` MCP tool) and `chat/websocket.ts` (`resolveEmailCwd`, for a chat opened against a mailbox).
`ChatPanelWrapper.tsx` hardcodes `workspace.id === 'email'` to trigger the second.
This traded a CODE dependency for a CONVENTION, which was the right trade at the time — the code
dependency is what put `officer-agent` into a restart loop on 2026-08-13 (`fbb6d6c7`). But the
convention has no enforcement on either side: if the plugin ever renames `emails.db`, moves to one
database per folder, or nests differently, core silently points the agent at a path that does not
exist. No import breaks, no type fails, no test covers it. The tool just goes quiet.
The clean version needs two things the platform does not have: a way for a plugin to ship an MCP tool,
and a way for a plugin to declare a chat context. Until both exist this stays — but it should be a
decision someone re-takes, not one that is inherited.
## Plugin repos — what writing their READMEs turned up
Three READMEs were written on 2026-08-16 (`music`, `offscale`, `example`) and pushed to their own repos.
Writing them meant reading each plugin against its own documentation, which is the cheapest audit there
is. Everything below was found that way and none of it is fixed.
**Offscale is still branded headscale in shipped, user-visible places.**
- [ ] `web/panels.ts` registers three panels named "Headscale servers", "Headscale", "Headscale" — these
are what a user reads in the panel picker.
- [ ] `web/OffscaleServerPicker.tsx:26` renders the literal heading `Headscale`.
- [ ] `web/ConsoleView.tsx:22` mints terminal session ids as `headscale-console-<id>`. That is persisted
state rather than a comment, so renaming it orphans live console sessions — cheap now, less so later.
- [ ] ~12 stale `/headscale` route paths in comments across `web/`, plus `App.tsx:78` in the platform.
- [ ] `PLUGIN.md` has ~11 stale self-references (`headscale_servers`, `listHeadscaleServers`, permission
key `headscale`, `/api/headscale`, panel keys, `useHeadscaleSection()`).
**Documentation that actively lies.**
- [ ] `plugins/offscale/PLUGIN.md:445-449` says the ACL assistant is deliberately unwired and must not be
wired up. It IS wired — `sidecar/assist.ts`, `claude-proxy.ts`, `web/PolicyAssistant.tsx` and the
manifest's `readOnlyWrites` all ship.
- [ ] `plugins/offscale/OFFSCALE_API.md` claims to publish the contract "in full" and omits six live
surfaces, including all of `/_officer/companion/*` and `/_officer/enroll/invites`.
- [ ] `plugins/music/MUSIC_API.md` says favourites and now-playing are served by the platform from
Postgres; the sidecar serves them itself, scoped by `X-Officer-User`. It also documents the playlist
item key as `<albumRel>/<file>` while the schema and favourites use the home path — the same file
contradicts itself.
- [ ] `plugins/music/MUSIC_API.md` documents now-playing as one snapshot per user with no mention of
`?device=`, which the schema makes part of the primary key. A mobile client written to that doc is
correct only by accident.
- [ ] `plugins/example/manifest.ts:12` lists `web/Router.tsx` as the frontend convention; discovery
requires `web/panels.ts` AND `web/layout.ts`. The same file documents an `ownerOnly` field that does
not exist and never did.
- [ ] The Companion contract docs the offscale code cites (`COMMS/OFFSCALE_COMPANION_API.md`,
`COMMS/OFFSCALE_INVITE_ENROLLMENT.md`) exist nowhere — `COMMS/` was deleted. Two of the plugin's
eight sections depend on a service with no published spec.
**Conventions a plugin author has to guess.** All verified in `discover.ts`/`mount.ts`, none documented
outside the example's new README:
- [ ] Panel `key`s and layout `appType`s must agree, and a mismatch fails SILENTLY — `PluginScreen.tsx`
pins `appTypes.allowed` with `fallback: allowed[0]`, so a typo renders the first panel.
- [ ] The `<appName>_` table prefix is unenforced. The barrel is `export *` across every plugin into one
`public` schema, so two plugins choosing the same unprefixed name collide with no error until push.
- [ ] A manifest is module-cached for the process lifetime: editing it does nothing until
`pm2 restart officer`, and `outdated` cannot see a version bump either.
- [ ] Permission-key prefixing is convention, not validation. Only a collision with a CORE key is refused,
silently, at mount, with a console error.
**Genuinely missing.**
- [ ] `plugins/offscale` spawns the `ssh` binary (`sidecar/ssh.ts`) but declares no `osDependencies`, so
install never checks for it and the Console fails at runtime instead of refusing at install.
`PLUGIN.md:421` already lists it as a host dependency — the manifest is the file that is wrong.
- [ ] `scripts/reindex-music.ts` resolves its port file from `process.env.DATA_PATH ?? cwd/data`, and
`DATA_PATH` stopped being an env var on 2026-08-13. Run from the repo it looks in
`platform/data/music/.server` and never finds it. One line.
- [ ] `plugins/music/web/MusicPlayerHost.tsx` header says it is mounted once in the persistent
DashboardLayout; it moved into `MusicDetail`. That is exactly the comment that would mislead
someone about why playback stops off-route.
- [ ] A plugin's `assets/icon.png` is only published while installed, so its real icon is invisible in the
store before install. The catalogue's `iconUrl` works around it, and an author has to know to add one.
## Waiting on the owner to run it
- [ ] **The AI usage panel works for one account; the multi-account half is unproven.** Landed and verified
live 2026-08-18 — click the harness pill in the chat composer and it shows Claude Code's own usage
display, limits with countdowns, tokens by day and by model.
What is NOT proven is the reason it was built: a second Anthropic login, the account tabs, and a
stored row surviving a switch. **Group C of `docs/ai-usage-panel.md`** is that, and test 10 is the one
most likely to be wrong — a refresh must not overwrite an account it could not measure, and that
failure is silent.
Deliberately deferred rather than forgotten: the owner switches accounts when the work needs it, and
the worst case is one wrong tab. Run Group C the first time a second login lands on this machine.
Also unexercised: the model dropdown no longer renders the literal string `claude-code` for a chat
that named no tier. Needs `officer-claude-code` restarted, not just `officer` — the parser in the
sidecar is what emits it.
- [ ] **Plan mode is live and has never been exercised.** Landed `1b811dfa` on 2026-08-17; all three
processes were restarted the same evening, so the code IS running. Not one part of it has been used:
not the toggle, not the plan card, not the round trip from the sidecar's pending promise to a browser
and back, not the stored column, not `settleAllPlans`.
**`docs/chat-plan-mode-test-plan.md` is the checklist** — eleven ordered tests, what each should show,
and what its failure looks like. Test 4 (approve) is the one most likely to be wrong: it is the case
that changed the design, because approving `ExitPlanMode` drops the harness into `default` rather than
back into `bypassPermissions`.
Safe to leave untested. Plan mode is opt-in and off by default, so a conversation that never touches
the toggle behaves exactly as it did before. The one failure worth knowing in advance is a turn that
never ends after a plan is answered — that would be a leaked `canUseTool` promise, and
`pm2 restart officer-claude-code` clears it.
Design and the probe results behind it: `docs/chat-plan-mode.md`.
## Known bugs
- [ ] **Background job logs appeared INSIDE an open file in the code editor.** Reported 2026-08-16 by the
owner, on the production host, running as a member with a `bun dev` background job streaming. The
editor had a file open; the runner's log output was rendered into that file's buffer. **It was never
saved** — the file on disk was intact, and the editor was not marked dirty as far as the report goes.
So this is a DISPLAY-layer mixing, not a write path: the bytes reached a buffer that only exists in
the browser.
Not reproduced, not diagnosed, and deliberately not guessed at further than this. What is worth
recording is where the two streams could meet, because both were added in the same week and they are
the only two things writing text into a panel:
- `useTaskDetail` polls `GET /chat/tasks/:id` every 2s while a job runs and returns `{kind:'log', text}`.
`CodeSurface` renders it — and `CodeSurface` is shared with the editor's own surfaces.
- The editor keys open files by PATH (`useEditorState`), while the task panel keys by TASK ID, and
several panel-scoped stores in between are keyed by `panelId` through `useGlobal` /
`usePanelMemory`. A key collision between those namespaces would produce exactly this: one panel's
text arriving in another's buffer, with no server involvement and nothing persisted.
**Screenshot 2026-08-16 answers the first question and kills two theories.** The tab carries a DIRTY
dot, and the log lines have Monaco line numbers continuing the file's own sequence (…180, then eight
lines of dotenv/POSTGRES_URL output, then 189 `</Link>`). So the MODEL was mutated — this is the
editor's content state, not its render, and the earlier "the file was okay" means it was simply never
saved. Note the splice is mid-file, not a replacement: JSX text and log text share line 180
(`Forgot?--env-file=.env --watch src/server.tsx`).
Ruled out by that: `CodeSurface` renders with shiki, not Monaco, so the two cannot share an editor
instance. And the app's only other Monaco mount (`FileViewer/renderers/EditorRenderer.tsx`) passes no
`path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`.
So the pollution arrives as TEXT through the editor's own content path — `openFile`, `setContent`, or
the end-of-turn refresh that re-reads every clean file. That last one is the prime suspect: it is the
only thing that writes a buffer without a keystroke, it runs exactly when a background job is
producing output, and it lands its result by path.
**Narrowed 2026-08-16, and a trap is now set.** `reloadFile` sets `originalContent` and clears
`isDirty`, so the end-of-turn refresh CANNOT produce the dirty dot in the screenshot — the refresh
path is out. Nothing in the app injects text into the page either: no `execCommand('insertText')`,
no synthetic keyboard events, no clipboard writes near the editor. That leaves Monaco's own change
event, which means it looks like a paste or a programmatic `executeEdits`.
**`docs/editor-buffer-corruption.md` is the procedure — read that when someone reports this.** A
trap is set: `reportSuspiciousEdit` in CodeEditor.tsx records any insertion over 200 characters
arriving while the editor is NOT focused, with the stack at that moment, into
`localStorage['officer:editor-buffer-trap']` (last five). It persists rather than only logging
because this arrives as a report hours later, when the console is long gone — the question becomes
"paste this key" instead of "what did you see?". Delete all of it once the cause is known.
One thing to rule out first, because it is mine and it is recent: `2b70a570` gave `<Editor>` a real
`path`, so models are now keyed by file URI globally rather than being per-mount `inmemory://`. Two
panels open on the same file now share one model. That should be an improvement, but it is a change
in exactly the layer that turned out to be involved.
Worth noting because it is the reason to look at all: an editor that can show content the file does
not contain is a correctness problem even when it never writes — the next person's instinct will be to
save the file to "fix" it.
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
paths and an outdated package name, so `installPi` always fires and floods the logs with
`EEXIST` noise. Should detect `@earendil-works/pi-coding-agent` at the real npm prefix.
- [x] **VNC mirror can orphan/duplicate x11vnc across sidecar restarts.** FIXED 2026-08-01 in the
Xvnc rewrite. The diagnosis was right and survived the move off mirroring: the running desktop is
tracked in module-level state, so a sidecar restart forgot it while the server kept running
orphaned, and `waitForPort` treated ANY listener on 5900 as success — so the next start reported
success while the browser talked to the stale process. Now `reclaimPort` frees 5900 (TERM, then
KILL after 2s) before spawning, and `waitForPort` also fails if the process we spawned has
exited, so a listener that is not ours can no longer be mistaken for a healthy start.
## Infra (alpha)
- [ ] **Delete `/etc/systemd/system/officer-vnc.service`.** Hand-installed unit (not in this repo)
that ran `vncserver :1 -geometry 1920x1080 -localhost yes -fg` with `Restart=on-failure`,
enabled at boot — it kept a whole parallel XFCE session alive on `:1` (283 MB, 166 tasks)
independently of the platform, and silently respawned it whenever the display was killed.
Obsolete now that the Desktop panel mirrors `:0` via x11vnc. Disabled 2026-07-15
(`systemctl disable --now`), but the unit file is still on disk. Nothing in the codebase
recreates it and nothing documents it, so delete the file rather than leave a mystery service
one `systemctl enable` away from returning.
- [ ] **`ufw` blocks port 9010 on the LAN.** Default deny incoming; only `22/tcp`, `80/tcp`,
`443/tcp`, and everything on `tailscale0` are allowed — so `http://192.168.47.196:9010` is
unreachable from the LAN while localhost and Tailscale work.
Fix: `sudo ufw allow from 192.168.47.0/24 to any port 9010 proto tcp`
## Backburner
- [ ] **Music tagging feature** (`/music`, Mp3tag-inspired). v1 was scoped as a two-panel workspace —
file browser left, table right, single "Load" context-menu item flattening audio files into the
table. Discarded before completion; would need `GET /file-browser/flatten-audio`, a
`useFilesAPI.flattenAudio` method, and the Music app panel rebuilt from scratch.
+2
View File
@@ -210,6 +210,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.5.1",
"hooks": "*", "hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
@@ -271,6 +272,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"hooks": "workspace:*", "hooks": "workspace:*",
"i18n": "workspace:*",
"state": "workspace:*", "state": "workspace:*",
"widgets": "workspace:*", "widgets": "workspace:*",
}, },
+43
View File
@@ -0,0 +1,43 @@
# 2026.08.21.01
The first release under this scheme, and the first deploy of the night's work. Everything since
`0.1.0`, which is to say everything.
## What is in it
- **Two plugins extracted**: `desktop` (VNC) and `browser` (the relay, its extension and its panels),
with the platform losing the VNC vocabulary it should never have had.
- **Themes regenerated** — all 22 from Omarchy, 84 tokens each, contrast-checked. The eleven that
shipped with 34 of an 84-token contract are replaced.
- **Spotlight**, on `shift-space`.
- **Chat fixes**: a turn waiting on a person is no longer killed as stalled, and an answer that goes
nowhere now says so.
- **Plugin infrastructure**: `importFresh` (a reinstalled plugin was being read from the deleted one),
`ownerOnly` paths, dependency `scope`, extensions published unauthenticated, `plugins:update`.
- An **Update button** in maintenance, and the **sub-agent budget guard**.
## Steps, in order, on every host
Same on all six — nothing here is host-specific.
1. `git pull --ff-only`
2. `bun install` — **required.** `bun.lock` changed and `sharp` was removed from the platform's
dependencies (it moved into atelier's `npmDependencies`). Without this the lockfile and the tree
disagree, and the frozen-lockfile rule turns that into a failed install later rather than now.
3. `bun run plugins:update`
4. `bun db:push` — **required.** `users.username` becomes `NOT NULL` and gains a case-insensitive
unique index.
5. `pm2 restart officer`
6. Then, and only for hosts that have them: `pm2 restart officer-claude-code officer-pty` — both load
changed sidecar code. **Not** the whole ecosystem, ever.
## The one thing that could have gone wrong, and did not
`db:push` refuses if any account has a null username, or if two differ only by case. **Checked on all
six before writing this**: 12 users across the fleet, zero nulls, zero case-collisions. If a future
host fails here, that is why, and the fix is data rather than schema.
## Post-deploy check
`pm2 jlist` — every restarted process `online` with a moved `restart_time`, and the platform's own
domain answering 200. `docs/servers.md` has the domain per host.
+42
View File
@@ -0,0 +1,42 @@
# 2026.08.21.01 — deployed
**6/6 hosts.** Every platform is on `eee73e77`, schema pushed, processes online and every domain
answering 200.
Rolled out by hand from `officer-dev`, one host at a time, because nothing wakes the commander yet.
| Host | Before | After | install | db:push | plugins | restarted | domain |
| ---------------------------- | ---------- | ---------- | ------- | ------- | -------------- | ------------------------- | ------ |
| `alpha` | `44f6c6d6` | `eee73e77` | ok | applied | 12/12 | officer, claude-code, pty | 200 |
| `fofos` | `6db26ae4` | `eee73e77` | ok | applied | 1/1 | officer, claude-code, pty | 200 |
| `edge-pertento-bridge` | — | `eee73e77` | ok | applied | 2/2 | officer, claude-code, pty | — |
| `production-pertento-bridge` | — | `eee73e77` | ok | applied | 1/1 | officer, claude-code, pty | — |
| `macbookpro` | `2e76cabf` | `eee73e77` | ok | applied | none installed | officer, claude-code, pty | — |
| `officer-dev` | `eee73e77` | `eee73e77` | ok | applied | 3/3 | officer, pty | 200 |
`bun install` reported no changes everywhere — the lockfile diff was `sharp` leaving, and nothing had
it as a transitive need.
## What was NOT done, and why
- **`officer-claude-code` was not restarted on `officer-dev`.** That sidecar holds the session doing
the deploy; restarting it would have ended the conversation mid-rollout. Every other host got it.
**Outstanding — run it when convenient.**
- **`fofos` did not sync its capabilities.** `git` refused the checkout as "dubious ownership" and
printed the usual `safe.directory` remedy. Ownership is actually correct (`pastilhas:pastilhas`,
running as `pastilhas`), so the cause is not what the message suggests and papering over it with a
config entry would hide a real question. Its one plugin updated fine. **Unexplained.**
- **`macbookpro` has no plugins installed**, so `plugins:update` was a no-op there. Not a failure —
worth knowing before someone expects the browser relay on it.
## Notes for the next one
- **The dirty-tree check was too strict.** `git status --porcelain` counts UNTRACKED files, and
`fofos` was skipped on the first pass for one stray `scripts/chk.ts` that a fast-forward could never
touch. The rule is `--untracked-files=no`; untracked files get a note, not a skip.
`prompts/deploy-commander.md` says "refuse a dirty tree" and should say which kind.
- **`macbookpro` needs `$SHELL -ic` and nothing else.** Exporting a PATH to reach `bun` broke `pm2`,
which is only on the interactive shell's path. Do not help it.
- **The pre-flight was worth it.** `users.username` became `NOT NULL` with a case-insensitive unique
index; checked across all six first — 12 users, zero nulls, zero collisions — so `db:push` applied
everywhere without a data fix.
+33
View File
@@ -0,0 +1,33 @@
# 2026.08.21.02
Command HQ made usable: a commander's transcript renders, and Live separates commanders from the
conversations somebody is having.
Frontend and one chat API route. **No schema change, no dependency change** — verified against
`deploy/2026.08.21.01..HEAD`: nothing touched `src/schema.ts`, `package.json` dependencies or `bun.lock`.
So there is no `db:push` and no `bun install` in this plan, and a host that runs one has run something
this release did not ask for.
## What is in it
- Three separate defects that each made a commander's conversation invisible: the pane navigated to
officer's session key instead of Claude's transcript id, then remounted and discarded the live chat at
the end of the first turn, then seeded `useChat` with messages that had not arrived yet.
- Live splits into `Agents (n)` / `Commanders (n)`, and `/chat/live` now carries `isCommander` so a client
with no room for tabs — the iPhone app's swipe order — can filter on it.
- The handoff watcher's controls, which were built and never rendered.
## Every host
In the order the procedure gives. Nothing here is host-specific.
1. `git pull --ff-only`
2. `bun run plugins:update`
3. `pm2 restart officer`
**`officer` only.** No sidecar changed. Restarting `officer` rebuilds the SPA on boot, which is what
ships the frontend — there is no separate build step to run.
## Then check
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else should have restarted.
+76
View File
@@ -0,0 +1,76 @@
# 2026.08.21.02 — deployed
**6/6 hosts.** Every platform the plan reaches is on `55ecbf4f`, `officer` restarted and back `online`,
and no other process on any host was touched by the restart step.
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this commander runs
on. See below.
Rolled out one host at a time from `officer-dev`, by the procedure in `prompts/deploy-commander.md`.
## The release
Tag `deploy/2026.08.21.02` → `55ecbf4f`; `package.json` says `2026.08.21.02`. They agree.
Plan: `deploy/2026.08.21.02.md` — pull, `plugins:update`, restart `officer` only. No `bun install`,
no `db:push`, and neither was run anywhere.
| Host | Before | After | plugins | restarted | restart_time | back |
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
| `alpha` | `eee73e77` | `55ecbf4f` | 12/12 | officer | 60 → 61 | online |
| `fofos` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 15 → 16 | online |
| `macbookpro` | `eee73e77` | `55ecbf4f` | none installed | officer | 2 → 3 | online |
| `edge-pertento-bridge` | `eee73e77` | `55ecbf4f` | 2/2 | officer | 4 → 5 | online |
| `production-pertento-bridge` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 6 → 7 | online |
| `deadpool-rezio-bridge` | `bd41dff5` | `55ecbf4f` | none installed | officer | 0 → 1 | online |
| `officer-dev` | `55ecbf4f` | `55ecbf4f` | not run | **none** | 155 | skipped |
Every host was clean of TRACKED changes and every pull was a real fast-forward. `pm2 jlist` after each
restart showed zero processes in any state other than `online`, so nothing was collaterally bounced.
Checkout paths were taken from each `officer` process's `pm_cwd`, not assumed — so `alpha`'s stale
second checkout at `~/dockers/officer.dev/platform` was never touched.
## What was NOT done, and why
- **`officer-dev` was skipped completely** — no pull, no `plugins:update`, no restart. This session runs
inside `officer-claude-code` on it, and its `officer` process would have been restarted out from under
the rollout. Its checkout is already at `55ecbf4f` (the tag was cut here), so **the code is present and
the running process is a build behind**. `pm2 restart officer` on `officer-dev`, when the owner is happy
with the rest, finishes this release. **Outstanding.**
- **`deadpool-rezio-bridge` was two releases behind, not one.** It came in at `bd41dff5` — the
`deploy/2026.08.21.01` commit — while every other host was at `eee73e77`. The fast-forward carried both,
14 files, and it is now level. Nothing was wrong with it; it simply had not had the interim commits.
- **`fofos` still cannot sync its capabilities.** Same `dubious ownership` refusal as `2026.08.21.01`,
same unexplained cause, still not papered over with a `safe.directory` entry. New this time and probably
the same root: its gitea plugin could not publish affordances —
`EACCES: permission denied, mkdir '/home/pastilhas/officerdev/capabilities/affordances/officerdev'`.
Both point at that capabilities checkout being owned by somebody other than `pastilhas`, which is worth
one `stat` before the next deploy. `plugins:update` exited 0 and the plugin itself updated. **Unexplained,
and now costing two things instead of one.**
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — that is the rule the last
report asked for and it worked.
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither.
## Notes for the next one
- **`macbookpro` needs the PATH helped after all — carefully.** The last report said "`$SHELL -ic` and
nothing else"; that is right for `pm2` and wrong for `bun`. `bun` is at `/opt/homebrew/bin/bun`, and
`.zshrc` does not put `/opt/homebrew/bin` on the interactive PATH, so `bun run plugins:update` failed
`command not found` before it ran a line. What works is **appending** that one directory for that one
command (`export PATH=$PATH:/opt/homebrew/bin`) and running `pm2` as a separate command. Appending
shadows nothing — `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. **Replacing** the PATH is
what broke `pm2` last time. `docs/servers.md` says "do not help the PATH" and should say "append, never
replace, and only for `bun`".
- **`$SHELL -ic` on `macbookpro` also aliases `ls` to `eza`**, which is not installed, so a probe using
`ls` reports a missing file that is plainly there. Use `command ls` or a plain non-interactive shell for
file tests.
- **`plugins:update` restarts sidecars itself.** It prints `sidecar: started` per plugin, so on `alpha`
twelve sidecars bounced during step 3 — before the plan's restart step, and not something the plan asked
for. That is the script's own behaviour and it is not new, but a report that only lists `officer` under
"restarted" is understating it. Worth a line in the plan format.
+30
View File
@@ -0,0 +1,30 @@
# 2026.08.21.03 — a drill
**No code change.** This release exists to watch the watcher fire, and to exercise the claim rule that
landed after the last one.
There is nothing to bump but the version and this file, and that is deliberate rather than lazy: a tag
whose `package.json` disagrees with it makes the commander stop, and a tag with no plan beside it makes it
stop too, so the smallest honest release is still a version and a plan.
## What is being tested
- The watcher noticing a tag and spawning an agent on its own, with nobody watching the composer.
- `scripts/deploy-claim.sh` taking the claim before anything is deployed, and the deploy recording success
through the same script.
- A second pass over the same version being refused rather than repeated.
## Every host
1. `git pull --ff-only`
2. `bun run plugins:update`
3. `pm2 restart officer`
`officer` only. No `bun install`, no `db:push` — nothing in this range touches a schema, a dependency or
the lockfile, and `git diff deploy/2026.08.21.02..HEAD` is two files.
## Then check
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else restarted.
`scripts/deploy-claim.sh state 2026.08.21.03` says `done`.
+83
View File
@@ -0,0 +1,83 @@
# 2026.08.21.03 — deployed
**6/6 hosts.** Every platform the plan reaches is on `3343b21a`, `officer` restarted and back `online`,
and no other process on any host was left in a state other than `online`.
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this deploy ran on.
See below; its skip is now two releases deep.
This was a drill. The release changes `package.json` and adds its own plan file, nothing else, and it
exists to watch the claim rule work. It did: `scripts/deploy-claim.sh claim 2026.08.21.03` was taken
before any host was touched, and `state` reads `done`.
## The release
Tag `deploy/2026.08.21.03` → `3343b21a`; `package.json` at that commit says `2026.08.21.03`. They agree.
Plan: `deploy/2026.08.21.03.md` — pull, `plugins:update`, restart `officer` only. No `bun install`, no
`db:push`, and neither ran anywhere.
| Host | Before | After | plugins | restarted | restart_time | back |
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
| `alpha` | `55ecbf4f` | `3343b21a` | 12/12 | officer | 61 → 62 | online |
| `fofos` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 16 → 17 | online |
| `macbookpro` | `55ecbf4f` | `3343b21a` | none installed | officer | 3 → 4 | online |
| `edge-pertento-bridge` | `55ecbf4f` | `3343b21a` | 2/2 | officer | 5 → 6 | online |
| `production-pertento-bridge` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 7 → 8 | online |
| `deadpool-rezio-bridge` | `55ecbf4f` | `3343b21a` | none installed | officer | 1 → 2 | online |
| `officer-dev` | `3343b21a` | `3343b21a` | not run | **none** | 155 | skipped |
Every host came in at `55ecbf4f` — the previous release, level for the first time in three deploys — and
every pull was a real fast-forward. Checkout paths were taken from each `officer` process's `pm_cwd`, so
`alpha`'s stale second checkout at `~/dockers/officer.dev/platform` was never touched. `macbookpro` was
awake.
**More than `officer` restarted, and the plan did not ask for it.** `bun run plugins:update` bounces each
plugin's sidecar as it goes: twelve on `alpha`, two on `edge`, one each on `fofos` and
`production-pertento-bridge`. Every one came back `online`. Nothing was left down.
## What was NOT done, and why
- **`officer-dev` was skipped completely** — no pull (it is already at the tag, which was cut here), no
`plugins:update`, no restart. This session runs inside `officer-claude-code` on it, and restarting
`officer` there ends the rollout mid-flight.
**Its running process is now two releases old.** `restart_time` is 155, unchanged since before
`2026.08.21.02` — so the same restart the last report left outstanding is still outstanding, and it now
carries both releases. The code for both is on disk. `pm2 restart officer` on `officer-dev` finishes
them, whenever the owner wants it. **Outstanding, and no longer new.**
- **`fofos` still cannot sync its capabilities.** Third deploy running, same two failures, same
unexplained cause:
- `capabilities: failed — fatal: detected dubious ownership in repository at
'/home/pastilhas/officerdev/capabilities'`
- `affordances: NOT published — EACCES: permission denied, mkdir
'/home/pastilhas/officerdev/capabilities/affordances/officerdev'`
Both say that checkout is not owned by `pastilhas`. Still not papered over with a `safe.directory`
entry, because a `safe.directory` line would silence the first message and leave the `EACCES` — they are
one cause, and one `stat` settles it. `plugins:update` exited 0 and the gitea plugin itself updated, so
this costs capability sync on that host and nothing else. **Unexplained.**
- **`fofos` still carries one untracked file**, `scripts/chk.ts`. A note, not a skip — the rule works.
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither, and
`deploy/2026.08.21.02..deploy/2026.08.21.03` is two files.
- **Nothing was verified beyond `pm2`.** `officer` is `online` with a moved `restart_time` on all six
hosts; no HTTP request was made to any domain. That is what the plan asked to check.
## Notes for the next one
- **The `macbookpro` PATH rule in `docs/servers.md` is now correct and was followed as written.**
`export PATH=$PATH:/opt/homebrew/bin` for the `bun` command, `pm2` as a separate command, and both
worked first time. Nothing to change.
- **`$SHELL -ic "pm2 jlist"` with stderr discarded came back empty once**, on `edge-pertento-bridge`, and
the same command with `2>&1` a second later returned the full list. Transient, and it looked exactly
like a dead pm2. Capture stderr and parse the JSON out of the stream rather than trusting the whole of
stdout to be JSON — an interactive shell puts its own noise (`starship`, aliases) in there.
- **`officer-dev`'s skip needs a home other than a report.** It has been carried forward twice now, and a
report is not a place work waits — the next deploy will write the same paragraph. Either the owner
restarts it, or it belongs in `TODO.md`.
+51
View File
@@ -0,0 +1,51 @@
# 2026.08.21.04
`/login` in the chat window, switching between Anthropic accounts from the usage pills, and a Spotlight
that can actually reach the plugins.
**This release has a SCHEMA CHANGE.** The previous two did not, and said so; this one adds
`anthropic_accounts`. Without the push, `GET /api/claude-auth/accounts` fails on a missing relation the
first time the usage panel is opened — so `bun db:push` is a step here, not an optional tidy.
No dependency change: nothing in `deploy/2026.08.21.03..HEAD` touches `package.json` dependencies or
`bun.lock`. Do not run `bun install`.
## What is in it
- **`/login` works in the chat composer.** `claude auth login` behaves the same through plain pipes as
through a terminal, so it is a subprocess with two reads and one write — no pty. Copy-link beside the
open-link button, because opening it here opens it in the browser already signed into the account you are
leaving.
- **Account switching from the usage pills.** `anthropic_accounts` keeps each login's OAuth credential
encrypted under a per-account key from the secret store, so a switch needs no browser round trip. An
account with no saved credential still gets a chip you can read — cached percentages and a correct reset
time — marked as needing `/login`.
- **Spotlight reached eight items.** Core filled all eight slots, so on a populated server every plugin was
unreachable. The cap is gone, rows draw the dock's own tiles, and the settings list went from nine
hand-written rows to twenty read off the screens that declare them.
- The ephemeral pane opens from the markup rather than an effect.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release, see above
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only; no sidecar changed. Restarting it rebuilds the SPA, which is what ships the frontend.
**`alpha` is already at this code**, deployed by hand as a preview before the tag. Its pull will be a
no-op and that is expected — run the remaining steps anyway rather than skipping the host, so its
`restart_time` moves with everyone else's.
**On `macbookpro`, append to the PATH for `bun`** — `export PATH=$PATH:/opt/homebrew/bin` — and run `pm2`
as a separate command. `docs/servers.md` has the detail; replacing the PATH is what broke `pm2` on an
earlier deploy.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing else restarted by step 4 — note that
step 3 restarts every plugin's sidecar on its own, which is the script's behaviour and worth recording
rather than omitting.
`scripts/deploy-claim.sh state 2026.08.21.04` says `done`.
+102
View File
@@ -0,0 +1,102 @@
# 2026.08.21.04 — deploy report
**6 of 6 hosts in scope succeeded.** `officer-dev` was skipped entirely — it is the host this agent runs
on. Seven platforms exist; six were deployable.
Target commit `98177c3b`, tag `deploy/2026.08.21.04`, `package.json` version `2026.08.21.04` — tag and
version agree.
Every host ran the same four steps from `deploy/2026.08.21.04.md`: `git pull --ff-only`, `bun db:push`
(the schema change), `bun run plugins:update`, `pm2 restart officer`. No host ran `bun install` — the plan
said not to.
---
## Per host
### `alpha` — succeeded
- HEAD `3cab16ca` → `98177c3b`. Tree clean, no untracked files.
- The pull moved only `deploy/2026.08.21.04.md` and `package.json`: the code was already there from the
hand deploy the plan describes. Expected, and the remaining steps ran anyway.
- `db:push` — changes applied. `anthropic_accounts` verified present by direct query.
- `plugins:update` — 12/12 updated in 44s, exit 0. **Twelve sidecars restarted by that step**: atelier,
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission,
vault. That is the script's own behaviour, not something the plan asked for.
- `pm2 restart officer` — `restart_time` 63 → 64, `online`.
### `fofos` — succeeded
- HEAD `3343b21a` → `98177c3b`. No tracked changes.
- **Untracked file present: `scripts/chk.ts`.** Left alone — a fast-forward cannot touch it, and it is
not a reason to skip a host.
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
- `plugins:update` — 1/1 (gitea), exit 0. One sidecar restarted: `officer-gitea`.
- `pm2 restart officer` — 17 → 18, `online`.
- The capabilities ownership fault recorded in `docs/servers.md` did not recur: `capabilities: pulled`.
### `macbookpro` — succeeded
- HEAD `b10a49c0` → `98177c3b`. Tree clean.
- Awake and reachable; no skip needed.
- `bun` reached with `export PATH=$PATH:/opt/homebrew/bin` appended for the `bun` commands only, `pm2`
run as a separate command, per the plan and `docs/servers.md`.
- `db:push` — changes applied.
- `plugins:update` — "no plugins installed — nothing to update", exit 0. No sidecar restarted.
- `pm2 restart officer` — 4 → 5, `online`.
- **The one thing not verified here:** `psql` is not on this machine's PATH under any prefix tried, so
`anthropic_accounts` was confirmed only by `db:push` reporting "Changes applied", not by querying the
table. Every other host was checked directly.
- Cosmetic noise from the interactive shell: `.zshrc:123: command not found: starship`. Harmless, and
unrelated to the deploy.
### `edge-pertento-bridge` — succeeded
- HEAD `3343b21a` → `98177c3b`. Tree clean.
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
- `plugins:update` — 2/2 (gitea, offscale), exit 0. Two sidecars restarted.
- `pm2 restart officer` — 6 → 7, `online`.
### `production-pertento-bridge` — succeeded
- HEAD `3343b21a` → `98177c3b`. Tree clean.
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
- `plugins:update` — 1/1 (gitea), exit 0. One sidecar restarted.
- `pm2 restart officer` — 8 → 9, `online`.
### `deadpool-rezio-bridge` — succeeded
- Logged in as `br`, checkout `/home/br/officerdev/platform`.
- HEAD `3343b21a` → `98177c3b`. Tree clean.
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
- `plugins:update` — "no plugins installed — nothing to update", exit 0. No sidecar restarted.
- `pm2 restart officer` — 2 → 3, `online`.
### `officer-dev` — SKIPPED, deliberately
Not pulled, no schema push, no plugin update, nothing restarted. This agent is a session inside
`officer-claude-code` on this machine, and restarting `officer` here ends the rollout mid-report. The
host is therefore one release behind the other six until the owner deploys it by hand — checkout
`~/officer-dev/platform`, and its working tree is already at `98177c3b` plus this report.
---
## What was checked, and how
- Every checkout path came from PM2 (`officer`'s `pm_cwd`), not from the table — so `alpha`'s second,
stale checkout was never a candidate.
- `pm2 jlist` after each restart: `officer` `online` with `restart_time` moved, on all six.
- A full sweep for anything not `online` on all six hosts afterwards: **none**. No process came back
`errored`, including the sixteen sidecars that `plugins:update` bounced across four hosts.
- `anthropic_accounts` queried directly with `to_regclass` on five hosts; on `macbookpro` only the
`db:push` output stands behind it.
## What was not done
- `officer-dev` — the whole host, as above.
- No host was skipped for a dirty tree; none had tracked changes.
- No process other than `officer` was restarted by this agent. The sidecars that moved were restarted by
`plugins:update` itself.
- `bun install` was not run anywhere, per the plan.
Local record: `data/deploys/2026.08.21.04.done`, written before this report.
+53
View File
@@ -0,0 +1,53 @@
# 2026.08.22.01
349 community themes, Spotlight made usable, prompt history that stops eating drafts, and the email
plugin's multi-account backend.
**No schema change and no dependency change.** Verified against `deploy/2026.08.21.04..HEAD`: nothing
touches a `databases/*/schema.ts`, `package.json` dependencies or `bun.lock`. So **no `bun db:push` and no
`bun install`** — a host that runs either has run something this release did not ask for. Note that the
previous release DID need a push; that was `anthropic_accounts` and it is already applied everywhere.
## What is in it
- **The theme collection.** 147 ports from omarchythemes.com, then 44 more rescued by supporting
`colors.toml` and `0x`-prefixed hex, then a fix to accent/secondary foreground derivation across 202 of
them. This is the bulk of the diff: 229 files, ~22.5k lines, almost all data.
- **Spotlight.** Opens on **alt-space** and nothing else, from anywhere including a focused text field.
The eight-item cap is gone — core items filled it, so every plugin tile was unreachable on a populated
server. Rows draw the dock's own tiles, and the settings list went from nine hand-written entries to
twenty read off the screens that declare them.
- **Prompt history.** Up/Down only recall from an empty composer, and any other keystroke ends a recall —
so a half-written thought is never swapped for one from last week.
- **The email plugin's multi-account backend**, which arrives via `plugins:update` rather than through this
repository. `?account=<id>` names a mailbox, `?account=all` spans them. **Absent is unchanged**, so
nothing that calls those routes today behaves differently — including the mobile app and the agent's
`email_db` tool. Only hosts with the email plugin installed are affected at all.
## Every host
1. `git pull --ff-only` — larger than usual, ~22.5k lines of theme data
2. `bun run plugins:update`
3. `pm2 restart officer`
`officer` only. Step 2 restarts each plugin's own sidecar, which is that script's behaviour and worth
recording in the report rather than omitting.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands and run `pm2` separately. See
`docs/servers.md` — replacing the PATH is what broke `pm2` on an earlier deploy.
## `officer-dev` — skip it, and do NOT report it as behind
The commander runs there and skips its own host, as always. But this time the host is **already on this
code**: `officer` was restarted there many times during the night as the work landed, so it is running the
tree this tag names rather than lagging. Report it as skipped and current, not skipped and stale.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing `errored`, including the sidecars step
2 bounced.
A theme picker still lists themes and one loads — the diff is data, so a bad parse would show as a missing
or unreadable theme rather than as a failed boot.
`scripts/deploy-claim.sh state 2026.08.22.01` says `done`.
+104
View File
@@ -0,0 +1,104 @@
# 2026.08.22.01 — deploy report
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
No host failed, no process came back `errored`.
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
## Per host
### `alpha` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
`~/dockers/officer.dev/platform` was not touched).
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
photos, soulseek, transmission, vault.
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
- All 19 processes `online` afterwards.
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
captured an explicit `0`.
### `fofos` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
touch it, and it was left alone. No tracked change.
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
fix of 2026-08-21 is holding.
### `macbookpro` — succeeded
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
interactive shell, exactly as the plan and `docs/servers.md` require.
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
### `edge-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
### `production-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
and the command still exited 0 — recorded so the next reader does not chase it.
- `pm2 restart officer`: 9 → 10, `online`.
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
All `online`, none touched.
### `deadpool-rezio-bridge` — succeeded
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
the others'. Not an error.
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
### `officer-dev` — SKIPPED, and current
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
another agent working in this same tree during the rollout. Its work was left alone; only this report was
staged, by path.
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
at `78db9c8c`, the tag — but the next release will include them.
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
## Verification
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
## What was not done
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
happy with the rest. It needs no pull.
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
files arrived, not that each parses.
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
+57
View File
@@ -0,0 +1,57 @@
# 2026.08.22.02
Theme favourites, a shortcuts modal on `Ctrl+?`, Command HQ rebuilt into two columns, 27 tests on the
authorization resolver, and the email plugin's multi-account backend.
**SCHEMA CHANGE.** `theme_favorites` is new, so `bun db:push` is a step. Without it, opening the theme
dropdown fails on a missing relation the first time it reads a starred list.
**No dependency change** — nothing in `deploy/2026.08.22.01..HEAD` touches `package.json` dependencies or
`bun.lock`. Do not run `bun install`.
## What is in it
- **Theme favourites**, per account, in `theme_favorites`. Star from the dropdown, from the browser
(`f`), or from anywhere (`Ctrl+Shift+F`). The lists rotate to the theme you are wearing rather than
sorting it to the top, so neighbours survive.
- **`Ctrl+?`** renders every shell shortcut from a registry — `state/shortcuts.ts` — ported in shape from
the owner's `vimazing`.
- **Spotlight is on `Shift+Space`.** It was `alt-space` in the last release. `Ctrl+Space` cannot work:
Brave claims it at the browser level and the key never reaches the page.
- **Command HQ is two columns** — navigation stacked into one 24% rail, transcript at 76%. **It migrates a
saved layout on load**, so anyone who has opened that screen will see their arrangement replaced once.
That is deliberate; the screen is `locked` and the only user contribution was column sizes.
- **27 tests on `permissions/authorize.ts`**, which had none. Its decisions are now pure functions, split
from the database calls. No behaviour change.
- **`assertPermissionTotality` reads the sockets that are served**, generated from one `WS_ROUTES`
declaration, instead of a parallel list of handlers.
- **The email plugin's multi-account backend**, via `plugins:update` — `?account=<id>` names a mailbox,
`?account=all` spans them, absent is unchanged. Only hosts with that plugin installed are affected.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
`docs/servers.md`.
## `officer-dev` — skip it, and it is already current
The commander runs there and skips its own host. That host was restarted repeatedly through the day as this
work landed, so it is running this tree rather than lagging. Report it as skipped and current.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved; nothing `errored`.
`theme_favorites` exists — `to_regclass('theme_favorites')` is not null. This is the one step whose absence
shows up as a broken dropdown rather than a failed boot, so verify it directly where `psql` is available and
say where it is not.
`scripts/deploy-claim.sh state 2026.08.22.02` says `done`.
+105
View File
@@ -0,0 +1,105 @@
# 2026.08.22.02 — deploy report
**6 of 6 target hosts succeeded.** `officer-dev` skipped (the commander runs there) and verified already
current. No host failed, no step was skipped for a fault, nothing came back `errored`.
Deployed 2026-08-22 from `officer-dev`. Tag `deploy/2026.08.22.02` → `68d60e40`, matching
`package.json` `2026.08.22.02`.
## Summary
| Host | HEAD before | HEAD after | Steps | `officer` restart | `theme_favorites` |
| ---------------------------- | ----------- | ---------- | ---------- | ----------------- | ----------------- |
| `officer-dev` | `68d60e40` | — | skipped | not touched (192) | present |
| `alpha` | `78db9c8c` | `68d60e40` | all 4 | 65 → 66, online | present |
| `fofos` | `78db9c8c` | `68d60e40` | all 4 | 19 → 20, online | present |
| `macbookpro` | `9b459b79` | `68d60e40` | all 4 | 6 → 7, online | present |
| `edge-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 8 → 9, online | present |
| `production-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 10 → 11, online | present |
| `deadpool-rezio-bridge` | `78db9c8c` | `68d60e40` | all 4 | 4 → 5, online | present |
Checkout paths were taken from each `officer` process's `pm_cwd`, not from a hard-coded list.
## Per host
### `officer-dev` — SKIPPED, and current
The commander session runs inside `officer-claude-code` on this machine, so the whole host was skipped as
the plan and `prompts/deploy-commander.md` both require: no pull, no `db:push`, no `plugins:update`, no
restart. It was already at `68d60e40` before the rollout started and `theme_favorites` is present, so it is
running this release rather than lagging. `officer` shows 192 restarts, untouched by this deploy.
**The owner restarts this host deliberately, once happy with the rest.** Strictly nothing is pending on it —
the tree, the schema and the running process are all already on this release.
### `alpha` — done
Tree clean (tracked and untracked). Pulled `78db9c8c` → `68d60e40`. `bun db:push` applied changes;
`backfill-session-models` reported 0 seeded, 32 already recorded. `bun run plugins:update` returned
**12/12 updated in 46s**, exit 0 — and it restarted all twelve plugin sidecars itself (atelier,
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault),
which is the script's normal behaviour and not something the plan asked for. `pm2 restart officer`: 65 → 66,
`online`. All 19 processes `online`. `to_regclass('theme_favorites')` returned the table.
### `fofos` — done
Tracked tree clean. **One untracked file, `scripts/chk.ts`** — noted, not a reason to skip, and untouched by
the fast-forward. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes (3 transcripts already recorded).
`plugins:update`: **1/1 (gitea)**, exit 0, its sidecar restarted by the script. `officer` 19 → 20, `online`;
all 8 processes `online`. `theme_favorites` present.
Note for anyone scripting against this host: its `.env` names the connection `OFFICERDEV_POSTGRES_URL`, not
`POSTGRES_URL`, and a naive `grep POSTGRES_URL` matches a comment line first. Same on `macbookpro`,
`edge-pertento-bridge`, `production-pertento-bridge` and `deadpool-rezio-bridge`; `alpha` and `officer-dev`
use `POSTGRES_URL`.
### `macbookpro` — done, awake
Reachable — not asleep this time. Tree clean. **It was two releases behind**: `9b459b79`, not `78db9c8c`
like the rest. Pulled straight to `68d60e40`.
`PATH` was appended with `/opt/homebrew/bin` for the `bun` commands and `pm2` run separately, per
`docs/servers.md`. `db:push` applied changes, exit 0. `plugins:update` exit 0 with **no plugins installed —
nothing to update**, so no plugin sidecars moved here. `officer` 6 → 7, `online`; all 6 processes `online`.
**`psql` is not installed on this host** — the plan's direct schema check could not be run the way it is
written. Verified instead through the platform's own `postgres` client (`bun --env-file=.env` against
`OFFICERDEV_POSTGRES_URL`), which returned `theme_favorites`. Same fact, different tool.
`$SHELL -ic` also prints `command not found: starship` from `.zshrc:123` on every invocation. Cosmetic, and
unrelated to this release, but it appears in the raw output above every command.
### `edge-pertento-bridge` — done
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **2/2
(gitea, offscale)**, exit 0, both sidecars restarted by the script. `officer` 8 → 9, `online`; all 8
processes `online`. `theme_favorites` present.
### `production-pertento-bridge` — done
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **1/1
(gitea)**, exit 0, sidecar restarted by the script. `officer` 10 → 11, `online`; all 15 processes `online`.
`theme_favorites` present.
### `deadpool-rezio-bridge` — done
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied
changes, exit 0. `plugins:update`: **2/2 (email, offscale)**, exit 0, both sidecars restarted by the script.
`officer` 4 → 5, `online`; all 9 processes `online`. `theme_favorites` present.
## What did not happen
- **No host was skipped for a fault.** The only skip is `officer-dev`, by design.
- **`bun install` was not run anywhere.** The plan says this release changes no dependency, and the plan is
what this agent executes.
- **Nothing but `officer` was restarted deliberately.** The plugin sidecars that bounced were restarted by
`plugins:update` itself — eighteen processes across five hosts, listed per host above. Recording them
here rather than reporting only `officer` is the plan's explicit instruction.
- **The one verification that had to change shape** is `macbookpro`'s schema check, for want of `psql`.
Everything else was verified exactly as written.
## Claim record
`scripts/deploy-claim.sh state 2026.08.22.02` → `done`. The local record
`data/deploys/2026.08.22.02.done` was written before this report was committed, so a push failure here
cannot cause a second rollout.
+64
View File
@@ -0,0 +1,64 @@
# 2026.08.23.01
The commander creation flow, rebuilt end to end — named, given a directory, and started by a button that
makes it interview you rather than waiting for a prompt somebody else wrote. Plus the delete path that
actually removes one, `/minesweeper`, and `docs:check` guarding the documentation's checkable claims.
**SCHEMA CHANGE.** `bun db:push` is a step. `command_hq_spawned` is new and `command_hq_sessions` gains
`name`. Without the push, Command HQ fails on a missing column the first time it lists a commander.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.22.02..HEAD`. `package.json`
moved only to add `engines.bun` and two scripts. **Do not run `bun install`.**
## What is in it
- **A commander is created, not configured.** Name it, choose its working directory, press Start. It reads
the directory, works out whether the job is already written down there, and either reads its
understanding back for confirmation or asks what it should be watching. The name is the brief — it is the
only thing the owner says before anything else, and it now reaches the model instead of only titling the
transcript.
- **`command_hq_sessions.name`** — the platform owns the name. It was only ever in the transcript's summary
record, which the harness cannot write until the first turn ends, so the list showed the opening brief
while the header showed the real name.
- **Deleting a commander removes it.** Four defects, each of which left something behind: the session kill
ran in the wrong process and always failed; the transcript was deleted while the harness was still
writing it, so it came straight back as an untitled chat; `chat_session_events` is keyed on officer's
session key while everything else uses the transcript id, so its rows were never matched; and the key was
resolved from an in-memory map that is empty for anything older than the process. The event rows carry
both ids, so they are asked when the maps cannot answer.
- **`GET /chat/live?kind=`** — `agent` (the default), `commander`, or `all`, with `counts` always
describing the whole set. Defaulting to agents means a client that forgets cannot be handed a commander;
the web list asks for `all` explicitly.
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
error — so answering a question during the reconnect window after a restart vanished and the card span
forever. Question answers and plan decisions now report it.
- **`/minesweeper`** — a wine session streamed to the browser over RFB. The session is not running on
`officer-dev`, so the route renders its "not running" state with a Start button. That is expected.
- **Usage limits are read live.** `limitsCache` was keyed on nothing, so switching Anthropic account showed
the previous account's percentages under the new name. The cache remains for the failure path only, keyed
per account.
- **`bun docs:check`** compares documented claims against values derived at run time — the permission
count, the PM2 entry count, the generated platform skill. It found both claims it was written to check
already wrong. **Required before tagging a release.**
- **Empty transcripts no longer list.** A file holding only harness metadata and no turns is not a
conversation; four had accumulated as "(untitled) · 0 msgs".
- **Bun is held at the 1.3 series** — `engines.bun` and `BUN_SERIES` move together. Do not run
`bun upgrade` on a host.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
`docs/servers.md`.
## `officer-dev` — skip it
This is the machine the rollout runs from, and restarting `officer` here ends the agent performing it.
Name it as skipped in the report. It is already current.
+134
View File
@@ -0,0 +1,134 @@
# 2026.08.23.01 — deploy report
**4 of 6 target hosts succeeded.** `officer-dev` skipped by the plan (the commander runs there) and
verified already current. Two hosts were **not reached at all**, both for access reasons that have nothing
to do with this release: `edge-pertento-bridge` will not complete an SSH handshake, and
`production-pertento-bridge` presented a **changed host key**, which is not something a deploy agent
clears. Neither was pulled, pushed to, or restarted. No host failed a step; nothing came back `errored`.
Deployed 2026-08-23 from `officer-dev`. Tag `deploy/2026.08.23.01` → `dee0b89b`, matching `package.json`
`2026.08.23.01`.
**Hosts landed on `e6f1388a`, one commit past the tag.** The plan says `git pull --ff-only`, which follows
`master`, and `master` had moved on by one commit — `plan: localizing the web UI, and the scanner that
sizes it`, three files: `CLAUDE.md`, `docs/localization.md`, `scripts/i18n/scan-hardcoded.ts`. Documentation
and a standalone script, no runtime code, no schema and no dependency. Recorded here because "deployed the
tag" would not be true of any host.
## Summary
| Host | HEAD before | HEAD after | Steps | `officer` restart | Schema |
| ---------------------------- | ----------- | ---------- | -------- | ----------------- | ------- |
| `officer-dev` | `e6f1388a` | — | skipped | not touched (237) | ✓ |
| `alpha` | `68d60e40` | `e6f1388a` | all 4 | 66 → 67, online | ✓ |
| `fofos` | `68d60e40` | `e6f1388a` | all 4 | 20 → 21, online | ✓ |
| `macbookpro` | `8b4a5b3b` | `e6f1388a` | all 4 | 7 → 8, online | ✓ |
| `edge-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `production-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `deadpool-rezio-bridge` | `68d60e40` | `e6f1388a` | all 4 | 5 → 6, online | ✓ |
Checkout paths came from each `officer` process's `pm_cwd`, not from a hard-coded list. "Schema ✓" is a
live check, not an inference from `db:push` exiting zero: `command_hq_spawned` exists and
`command_hq_sessions.name` exists, queried per host against that host's own database.
## Per host
### `officer-dev` — SKIPPED, and current
The commander session runs inside `officer-claude-code` here, so the whole host was skipped as the plan
requires: no pull, no `db:push`, no `plugins:update`, no restart. It was already at `e6f1388a` and both
schema objects are present, so it is running this release rather than lagging. `officer` shows 237 restarts,
untouched by this deploy. Nothing is pending on it — the owner restarts it deliberately if they want to.
### `alpha` — done
Tree clean, tracked and untracked. Pulled `68d60e40` → `e6f1388a`. `bun db:push` applied changes;
`backfill-session-models` reported 0 seeded. `bun run plugins:update` returned **12/12 updated in 48s**,
exit 0, and restarted all twelve plugin sidecars itself — atelier, bitcoin-wallet, email, gitea, invoices,
jellyfin, music, offscale, photos, soulseek, transmission, vault. Then `pm2 restart officer`: 66 → 67,
`online`. Every other PM2 entry `online`.
### `fofos` — done
Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied changes. `plugins:update`: **1/1 in 8s**
(gitea), which restarted `officer-gitea`. `pm2 restart officer`: 20 → 21, `online`. No trace of the
`dubious ownership` / `EACCES` pair that broke its capabilities sync for three deploys — `capabilities:
pulled` came back clean, as it has since the 2026-08-21 `chown`.
### `macbookpro` — done
Awake and reachable. Tree clean. Pulled `8b4a5b3b` → `e6f1388a` — two releases behind, so it picked up
2026.08.22.01 and .02 as well. `db:push` applied changes (3 plugin schemas generated: atelier, kiosk,
music). `plugins:update`: `no plugins installed — nothing to update`, exit 0. `pm2 restart officer` was run
as a **separate** command per `docs/servers.md`: 7 → 8, `online`.
`PATH` was appended, never replaced — `export PATH="$PATH:/opt/homebrew/bin"` inside the interactive shell,
so `bun` resolves and `pm2` stays where nvm put it. `.zshrc` still fails on a missing `starship` on every
non-interactive login; noise, not a fault, and it has been there for at least three deploys.
### `deadpool-rezio-bridge` — done
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied
changes. `plugins:update`: **2/2 in 8s** (email, offscale), restarting both sidecars. `pm2 restart officer`:
5 → 6, `online`.
### `edge-pertento-bridge` — NOT REACHED
**Four attempts, ConnectTimeout up to 60s: `Connection timed out during banner exchange`.** The TCP
connection succeeds — `nc -z 100.64.0.15 22` reports the port open and `ssh -v` logs `Connection
established` — and then sshd never sends its version string. Tailscale shows the node `active; direct`, so
the tunnel is up and this is not a tailnet problem.
That is a host-side sshd or load problem, not a credential one and not a rate-limit one. Nothing was run
against it: it is still on whatever it was before this release, which I could not read, because reading it
needs the same handshake. It also did not answer HTTPS on its tailnet address during the probe.
**This host needs a look before the next release**, and the check is one command from anywhere on the
tailnet — `ssh edge-pertento-bridge hostname`. If it still hangs, the box wants a console.
### `production-pertento-bridge` — NOT REACHED, host key changed
```
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
The fingerprint for the ED25519 key sent by the remote host is
SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww
Offending ECDSA key in /home/pastilhas/.ssh/known_hosts:37
```
**I did not clear the entry and did not connect.** Accepting a new host key is a trust decision, and a
deploy agent that removes a `known_hosts` line to get its release out has removed the only warning the
owner would ever get. `StrictHostKeyChecking` did exactly what it is for.
What is known, all read locally without touching the host:
- `known_hosts` holds three keys for this name: ED25519 `SHA256:dw7kG2MDftuLDT1CduEO+OVYuMVHsx02Cnwikj7GS/4`
(line 33), RSA (line 36), ECDSA (line 37, the one ssh names as offending).
- The host now offers ED25519 `SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww` — a different key from
the ED25519 already trusted.
- Tailscale shows it `active; direct`, port 22 accepts TCP, and it did not answer HTTPS on 100.64.0.12
during the probe.
The benign explanation is a rebuild or a regenerated host key — this is `PPROD`, a bridge that has been
reinstalled before. The other explanation is the one the warning is about. **Settle it out of band**: read
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the machine's own console, compare it to
`63S0GK…`, and only then drop the stale lines with
`ssh-keygen -f ~/.ssh/known_hosts -R production-pertento-bridge`. Do not compare it over SSH; that is the
channel in question.
Until then this host is a release behind — it was at `68d60e40` as of the 2026.08.22.02 report.
## What I did not do
- **Did not deploy `edge-pertento-bridge` or `production-pertento-bridge`**, and did not force either.
Neither was pulled, neither had a process restarted, and neither has this release's schema.
- **Did not touch `~/.ssh/known_hosts`.**
- **Did not run `bun install`** — the plan forbids it and `bun.lock` is unchanged across
`deploy/2026.08.22.02..HEAD`.
- **Did not run `bun run docs:check`** — not the commander's job; it is run before the tag.
- **Did not restart anything the plan did not name.** `officer` only, per host. The plugin sidecars that
bounced were restarted by `plugins:update` itself, which is that script's own behaviour: 12 on `alpha`,
1 on `fofos`, 2 on `deadpool-rezio-bridge`.
- **Did not touch `officer-dev`** at all.
The local record `data/deploys/2026.08.23.01.done` was written before this report, as the protocol requires:
the record is what prevents a second attempt, and pushing this file is paperwork that is allowed to fail.
+53
View File
@@ -0,0 +1,53 @@
# 2026.08.23.02
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
push, `/sendoff` fails on a missing relation the first time it lists anything.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
moved only its version. **Do not run `bun install`.**
## What is in it
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
`Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually
browses — the owner's real login home, a member's managed home — because those are different directories
and writing to the wrong one makes a delivery invisible rather than wrong.
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
accounts. The owner bypasses this as a SENDER only.
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
the catalogue syncs.
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
days, and the last rollout spent four attempts and a minute apiece discovering that.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
## `officer-dev` — skip it
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
+69
View File
@@ -0,0 +1,69 @@
# 2026.08.23.02 — deploy report
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
## Per host
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
| `officer-dev` | — | — | — | — | — | SKIPPED |
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
`sendoff_senders` are new tables, so the diff was additive.
## What restarted, beyond what the plan asked for
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
not a fault, and it is the larger part of what moved:
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
- `fofos` — one (`officer-gitea`).
- `production-pertento-bridge` — one (`officer-gitea`).
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
- `macbookpro` — none; no plugins are installed there.
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
on any host was `errored`.
## Hosts the plan flagged
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
steps, with `pm2` run separately through `$SHELL -ic`.
## Things worth knowing, none of them failures
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
it, and it was left alone.
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
## What was not done
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
+64
View File
@@ -0,0 +1,64 @@
# 2026.08.23.03
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
changes and nothing reads the new table yet.
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
existing data.
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
`bun install`.**
## What is in it
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
`db:push` and is a later release.
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
step here. It never overwrites an existing row.
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
(`en` → `en-GB`), with no database migration.
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
create.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
exists and holds four rows on that host. `db:push` can fail and still exit 0.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## The check that matters this release
```sql
select count(*) from roles; -- expect 4
```
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
per host in the report.
No direct SQL is needed and none is authorised for this release.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
## `officer-dev` — skip it
The rollout runs from here. It already holds the release and the four rows, verified locally.
+42
View File
@@ -0,0 +1,42 @@
# 2026.08.23.03 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
attempted, nothing spent on it).
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
authorised and none was needed.
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
## Per host
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
`online`** — not just `officer`, every entry.
## Notes, and what was not done
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
restarting `officer` on it is the owner's deliberate last step.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
never have collided with it, so it is not a reason to skip a host.
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
before `officer` itself was touched.
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
- **Nothing was retried and nothing was repaired.** No step failed on any host.
+61
View File
@@ -0,0 +1,61 @@
# 2026.08.23.04
The web UI can be read in Portuguese. The shell and the shared components are fully keyed — 581 keys across
two locales, with the build failing on a missing one — plus an endpoint for spawning an agent, and spawned
agents becoming visible in both directions.
**No schema change.** Nothing under `src/databases/**/schema.ts` moved across
`deploy/2026.08.23.03..HEAD`. `bun db:push` is still a step because it is cheap and it verifies the
database agrees with the code, but it should report no changes.
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
## What is in it
- **Localization, phases 0 through 2.** `src/apps/officer-web` and `src/workspaces/components` have no
hardcoded user-facing strings left: 555 and 47 occurrences respectively, both now zero. `en-GB` and
`pt-PT` are complete and in step at 581 keys.
- **Every locale ships a complete catalogue.** There is no fallback between regional variants, so a
Portuguese reader is never served Brazilian wording. What enforces that is a **key-parity test that fails
the build** on a gap — verified by removing one key and watching it go red.
- **The language picker offers only locales that exist.** It listed about twenty codes, eighteen of which
silently rendered English. Stored bare codes widen on read (`en` → `en-GB`), with no database migration.
- **Dates, numbers and plurals follow the account's locale**, not the browser's, through `useFormat()` and
`Intl.PluralRules`. Roughly 23 call sites moved.
- **`POST /api/sub-agents/spawn`** — an endpoint for starting an agent from a prompt, for callers that are
not a chat and so cannot reach the harness's own tool. Budget-checked before spawning, and it refuses with
a 429 naming the window rather than burning the last of one.
- **Spawned agents are visible in both directions.** A commander's pane lists its children as links; opening
one shows which commander started it, linking back.
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
error — so answering a question during a restart window vanished and the card spun forever.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.04`
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: that means
this tree and that host's database disagree about something this release did not touch.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## What to check, this release
The visible change is the language picker in profile settings. It should offer exactly **English (UK)** and
**Português (Portugal)** and nothing else. A host still offering twenty languages did not pick up the
frontend bundle.
No direct SQL is needed and none is authorised.
## Hosts
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
- **`production-pertento-bridge`** — deployed cleanly last release after being two behind. Nothing expected.
## `officer-dev` — skip it
The rollout runs from here. It is already at this commit and `officer` has been restarted on it.
+23
View File
@@ -0,0 +1,23 @@
# `deploy/`
One file per release, and one report per release.
```
deploy/
├── 2026.08.21.01.md the plan — what to run, in order, on which hosts
└── 2026.08.21.01.report.md what actually happened, written by the commander
```
The plan is written by hand before the release commit. The report is written by the deploy commander
afterwards and pushed back here.
**A release is a tag**, `deploy/<version>`, and nothing else triggers a deploy — not a commit message,
not a branch. A tag cannot be written into prose by accident, which a code word can and which every
document about deploying does.
**What marks a release deployed is LOCAL**: `$OFFICER_ROOT/data/deploys/<version>.done`. Not the report.
If the lock were the pushed report, a failed push would mean the release looked undeployed and the next
tick would roll it out again — and pushes here fail for unrelated reasons. So the report is an artefact,
valuable and not load-bearing.
See `prompts/deploy-commander.md`.
+164
View File
@@ -0,0 +1,164 @@
# Affordances
**Status: BUILT.** Agreed and built 2026-08-19. `src/servers/affordances/load.ts` reads them,
`publish.ts` copies a plugin's in on install, and `claude-manager.ts` compiles them into every session.
Two exist: `image-rendering` and gitea's. **Not built:** scoping (everything loads unconditionally) and
delivery to opencode, which currently receives none.
## What one is
> **An affordance is something the surface on the other end of the conversation offers the agent —
> declared by the code that implements it, and read by the agent at runtime.**
>
> Not what the agent can do. What the client will honour.
## Why it needs to exist
The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a
prompt; everything about the surface it is speaking _through_ has to be smuggled in as prose somebody
maintains by hand.
The worked example is images. Officer's chat renders markdown images — `ChatImage` is wired into
`react-markdown` — but an `<img>` carries no bearer token, so it can only load what the browser can fetch
unauthenticated. A fresh agent knows none of that. It writes `![](/home/me/chart.png)`, nothing renders,
and nothing tells it why.
Three properties make prose the wrong container:
- **It drifts.** The file describes a workaround; the code has a reason. Neither knows about the other.
- **It is per-machine.** It lives in one repository, copied by hand.
- **A plugin cannot contribute to it.** Install a plugin that adds a surface and no agent learns it
exists — which is the case that matters most, because a plugin is exactly a new surface arriving at
runtime.
## The boundary
Four neighbours, easily confused:
| | answers |
| -------------- | ---------------------------------------------------- |
| **tool** | what the agent can _do_ — it acts, something happens |
| **permission** | what this _account_ may reach |
| **skill** | instructions the agent _follows_ |
| **affordance** | what the _client_ will render, accept or ignore |
Three tests settle almost everything.
### 1. Would it change if the same agent talked to a different client?
A phone app, a terminal, a webhook, a cron run with no human at all. If yes, it is an affordance.
Rendering an image is; reading a file is not.
### 2. Does the agent have to guess it today?
If the honest answer is "it writes the markdown and hopes", that is a missing affordance.
### 3. Can it be fixed instead? — then fix it
**This is the one that is easy to get wrong, and the most expensive when you do.** An affordance is for
something _irreducible_ about the surface. A defect is not irreducible. It is a defect.
Both candidates written on 2026-08-19 failed this test:
- **"Links open in the same tab, so warn the agent."** `a` was simply missing from `MD_COMPONENTS` in
`MessageBubble`. One line. Fixed.
- **"`AskUserQuestion` never returns an answer, so don't wait for one."** True when the code comment was
written; already fixed by the time the affordance was drafted. It would have shipped a permanent
instruction to work around a bug that no longer existed.
The failure mode is specific and worth naming: **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 ever a bug. The second example is worse than the first — it would have taught
every future agent something false, in a file whose whole purpose is to be believed without checking.
Image rendering earns its place precisely because it fails this test in the other direction: "the user's
device fetches the URL" is how `<img>` works. No code change makes it untrue.
## The rule that makes it worth having
**An affordance is declared where it is implemented.** `ChatImage` declares that images render, because
`ChatImage` is what makes them render. The moment it becomes a list maintained somewhere else, it is the
hand-written `CLAUDE.md` problem again with a better name — and this time with a build step.
Same posture as `permissions/registry.ts`, and for the same reason: one enumeration, owned by the code it
describes, which cannot quietly disagree with reality.
## The file
Markdown with YAML frontmatter, like every other item in `capabilities/`. Frontmatter is for the
PLATFORM; the body is for the agent.
```markdown
---
name: Image rendering
description: Use when you have an image to show the user in chat.
version: 1
---
`![alt](https://…)` renders as an image. **The user's device fetches the URL**, so it must be
reachable from there — a local path, `file://`, or anything behind authentication renders nothing,
and fails silently.
```
| field | |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | becomes the heading in the compiled block, so the agent can index it |
| `description` | when to reach for it. Costs the agent nothing — it is **not** sent |
| `version` | an INTEGER, not semver, matching `capabilities/agents/`. A plugin-contributed affordance sits near two version numbers meaning different things: this integer is the revision of _this text_, the plugin's semver is the plugin's. Do not reconcile them |
A broken frontmatter header does not lose the body — `load.ts` falls back to the path for `name` rather
than dropping the file. The agent needs the prose far more than the label, and a file that vanished over
a stray colon would be very hard to notice.
### Keep the body short
Every affordance is loaded into every conversation, so it is charged to the context budget of every turn.
State the constraint, name the silent failures, say what to do instead. Nothing else.
Write it as **facts about the client**, not instructions to the agent. "The user's device fetches the URL"
survives a redesign of the agent's behaviour; "always use a public URL" does not, and is one step removed
from the reason.
## Where they live
```
capabilities/affordances/
officer/core/ ← core, shipped by the setup script from the capabilities repository
image-rendering.md
officerdev/gitea/ ← contributed by the gitea plugin on install
gitea.md
```
`<publisher>/<plugin>`, taken from the manifest verbatim. `officer/core` is not a plugin: it is the
platform's own. The two spellings mark two different things and should not be normalised into one.
A plugin ships an `affordances/` directory in its repository; installing copies it in, uninstalling
removes it — the same shape as `assets/` and `publishAssets`, deliberately, because two mechanisms for
"the plugin ships some files" would drift.
**The namespace is derived from where the file lands**, never from anything inside it — the same rule as
`mountPrefix` and the notification catalogue.
## How they reach the agent
`claude-manager.ts` calls `affordanceBlock()` once per session and prepends the compiled block to the
system prompt. Read at spawn rather than at module load, so a plugin installed an hour ago is live on the
next message.
`description` is deliberately **not** included in the compiled block: it answers "when should I reach for
this", which only matters when an agent chooses what to load. Everything here is already loaded, so it
would be paid for and unread.
### Deliberately dumb, for now
No filtering. Every affordance is compiled into every conversation. A `surface` field was drafted and
dropped because it wore one name for two different axes: for image rendering it means _what the client
renders_, for gitea _what tooling exists on the machine_. Picking the wrong axis and writing files against
it costs more than loading two extra paragraphs.
Fine at two files. Not fine at twenty. See `TODO.md`.
### opencode gets none
`affordanceBlock()` has exactly one caller and it is the Claude sidecar. An opencode turn runs with no
affordances at all, so an agent driven through it still guesses at images. Tracked in `TODO.md`.
+19
View File
@@ -57,6 +57,25 @@ so the walk exists — this is a second consumer of it, not a new class of work.
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
still null contributes to no directory (see above). still null contributes to no directory (see above).
**`/chat/live` returns only agents unless asked otherwise.** `?kind=` takes `agent` (the default),
`commander`, or `all`; anything else is a 400 rather than a silent fallback, and the plural forms are
accepted because the web client's own type is already `'agents' | 'commanders'`.
The default is the point. Commanders run background work and the phone swipes between live chats one at a
time, with no room for tabs and no way to skip a row — so a client that forgets the parameter must not be
handed a deploy commander. Defaulting to `agent` means forgetting can only ever under-report. The two
callers that genuinely want both say `kind=all`, which is a fair price: they are few, they are on screens
built to show both, and they are the ones positioned to notice if the answer is wrong.
`counts` describes **every** live session regardless of the filter, so a filtered caller still renders
`Agents (n) / Commanders (n)` without a second request. That is what stops a server-side filter from
costing more round trips than the client-side one it replaced.
Which rows are commanders comes from `command_hq_sessions`, **not** from the working directory — a
commander chooses its own cwd, so the directory answers nothing. `GET /chat/sessions` reports no
`isCommander` at all, because it removes commanders entirely; a list that excludes them has nothing to say
about which of its rows is one.
--- ---
## Landmines ## Landmines
+285 -49
View File
@@ -5,14 +5,30 @@ it, kept current as answers landed.
**Status:** data model, the full REST API including both creation paths (web and `POST **Status:** data model, the full REST API including both creation paths (web and `POST
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline /commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
failures — none of them this feature's). **Needs `bun db:push`** before any route answers with real data; failures — none of them this feature's). `bun db:push` and a restart of `officer` have both since run
without it every route throws a Postgres "relation does not exist" 500. A member also needs against the dev machine, so the backend is live. A member still needs `confineUserTree` re-run once if
`confineUserTree` re-run once if their account predates tonight — see "A member could not reach it" under their account predates that — see "A member could not reach it" under "Working directory".
"Working directory". **Frontend not built yet on the web side.** The mobile side is further along —
`apps/commandhq` exists and typechecks against this router (see `COMMS/command-hq/mobile.md`) — and as of **Web frontend v1 built 2026-08-18**, same session: `/command-hq` and `/command-hq/:sessionId`, three
this revision has what it asked for to create a commander, though it has not yet been exercised against a panels per "Layout, v1" below (`command-hq-categories`, `command-hq-grid`, `command-hq-detail`) under
running server on either side. Every claim in this file is checked against the code, not aspirational — `officerdev/src/apps/CommandHQ/`, registered in `AppRegistry.tsx`, a `CORE_DOCK_ITEMS` tile, and a
see "API reference" for what a client can call today. `usePageTitle` rule. **Now exercised against a running server** — a real commander created, categorised,
armed as a watcher and observed running, its auto-generated name traced back to the actual process on this
machine (see "Auto-naming" and the `>/dev/null` finding below). The composer creates a commander the same
way `/chat` always has (an ordinary turn with `context: 'command-hq'`); once the harness reports a
transcript id the URL is replaced onto it, and if a category was selected when composing, a `PATCH` sets it
client-side — `fileAsCommander` itself always files uncategorised, the same race `POST /commanders/new`
resolves server-side for mobile, resolved here the client-side equivalent. Rename, category, disarm and
delete all go through Command HQ's own routes, never `/chat`'s — the two are scoped to different
directories. Delete asks for confirmation inline (grid card and detail bar both), same shape as `/chat`'s
own session-row delete. Tag UI is still deliberately absent, per "Tags — infrastructure now, UI not
specified" below.
The mobile side is further along on live exercise — `apps/commandhq` exists and typechecks against this
router (see `COMMS/command-hq/mobile.md`) — and as of this revision has what it asked for to create a
commander, though it has not yet been exercised against a running server on either side either. Every claim
in this file is checked against the code, not aspirational — see "API reference" for what a client can call
today.
## API reference — build against this ## API reference — build against this
@@ -20,33 +36,40 @@ Base path `/command-hq`, `core` permission (every signed-in account, no grant to
to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/router.ts` — read it directly to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/router.ts` — read it directly
for exact request/response shapes; the table below is the map, not the full contract. for exact request/response shapes; the table below is the map, not the full contract.
| Method | Path | Body | Notes | | Method | Path | Body | Notes |
| ------ | ------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------ | ------------------------------------ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` | | GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` |
| POST | `/categories` | `{name}` | 409 on a duplicate name for this user | | POST | `/categories` | `{name}` | 409 on a duplicate name for this user |
| PATCH | `/categories/:id` | `{name}` | rename | | PATCH | `/categories/:id` | `{name}` | rename |
| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them | | DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them |
| GET | `/tags` | — | schema exists; no UI planned for v1 | | GET | `/tags` | — | schema exists; no UI planned for v1 |
| POST | `/tags` | `{name}` | | | POST | `/tags` | `{name}` | |
| DELETE | `/tags/:id` | — | | | DELETE | `/tags/:id` | — | |
| GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below | | GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below |
| POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder | | POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below | | GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` | | GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?}` | `name` proxies to `renameClaudeSession` — the same rename `/chat` uses | | GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` | | POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
| POST | `/commanders/:sessionId/tags` | `{tagId}` | | | PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | | | DELETE | `/commanders/:sessionId` | — | deletes the TRANSCRIPT (`/clear` chain and all) via `deleteClaudeSession`, then the row. Not reversible, and not the same thing `/purge` does — see "The `/chat` ↔ `/command-hq` relationship" below |
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not **A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
duplicate it — `GET /commanders/:sessionId` returns only `{sessionId, categoryId, tagIds, running, duplicate it — `GET /commanders/:sessionId` returns Command HQ's own metadata: `{sessionId, categoryId,
createdAt}`. A client wanting the full picture calls both. tagIds, running, watcherArmed, watcherArmedAt, watcherStoppedAt, watcherLastFiredAt, cost, createdAt}`. A
client wanting the full picture calls both; a client wanting the raw cost series calls `.../turns` as well
— `cost` here is only the summary (`{turnCount, inputTokens, outputTokens, totalUsd}`), not the list.
**`GET /commanders` is the grid endpoint** and returns the richest shape: every field `/chat/sessions` **`GET /commanders` is the grid endpoint** and returns the richest per-card shape: every field
already returns per session (`id`, `title`, `cwd`, `createdAt`, `updatedAt`, `messageCount`, …) plus `/chat/sessions` already returns per session (`id`, `title`, `cwd`, `createdAt`, `updatedAt`,
`categoryId`, `tagIds`, and `running` — a boolean, derived live from the same `task:started`/ `messageCount`, …) plus `categoryId`, `tagIds`, `running` (derived live from the same `task:started`/
`task:notification` fold `/chat`'s own background-job tray uses, not a stored value. This is the field to `task:notification` fold `/chat`'s own background-job tray uses), the watcher-arming fields, and the same
poll if a client wants to know "did my watcher die" without opening it. `cost` summary the detail route returns — one aggregate query per card, the same cost class `running`
already accepts for this grid. This is the field to poll if a client wants to know "did my watcher die"
without opening it, or "what has this one cost me so far" without a second request.
**A row whose transcript is gone is silently absent from every response.** Never a tombstone, never a 404 **A row whose transcript is gone is silently absent from every response.** Never a tombstone, never a 404
with a special shape — it simply is not in the list. See "The /chat ↔ /command-hq relationship" below for with a special shape — it simply is not in the list. See "The /chat ↔ /command-hq relationship" below for
@@ -183,19 +206,60 @@ an accident of copying a plugin schema).
One row per commander. One row per commander.
| column | notes | | column | notes |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user_id` | owner of the row, always the same account that owns the session | | `user_id` | owner of the row, always the same account that owns the session |
| `session_id` | **Claude's transcript uuid**, not Officer's `sessionId` — the same id `chat_session_models` keys on, for the same reason: everything else that joins to a session (models, events) uses this id | | `session_id` | **Claude's transcript uuid**, not Officer's `sessionId` — the same id `chat_session_models` keys on, for the same reason: everything else that joins to a session (models, events) uses this id |
| `category_id` | FK to `command_hq_categories`, nullable — one category per commander (see below) | | `category_id` | FK to `command_hq_categories`, nullable — one category per commander (see below) |
| `created_at` | | | `watcher_armed` | should the platform keep re-arming this watcher on every fire — see "The watcher loop" |
| `watcher_task_id` | the currently-tracked watcher task, nullable |
| `watcher_armed_at` | since when the CURRENT run has been going; set only on the false→true transition, untouched by re-arms |
| `watcher_stopped_at` | when the current run ended; set on disarm, cleared on the next arm — paired with `watcher_armed_at` |
| `created_at` | |
No `name` column (see "Naming"). No `status` column, deliberately: liveness is **derived**, the same way No `name` column (see "Naming"). No `status` column, deliberately: liveness is **derived**, the same way
the background-job tray already derives it — fold `chat_session_events` for the session, `task:started` the background-job tray already derives it — fold `chat_session_events` for the session, `task:started`
minus `task:notification` (`collectRunningTasks` in `websocket.ts` is the existing implementation of exactly minus `task:notification` (`collectRunningTasks` in `websocket.ts` is the existing implementation of exactly
this fold; reuse it, do not reinvent it). A stored status would be a second source of truth that goes stale this fold; reuse it, do not reinvent it). A stored status would be a second source of truth that goes stale
precisely in the case this feature is used for (see "Later" — silent death on sidecar restart) and nothing precisely in the case this feature is used for (see "Later" — silent death on sidecar restart) and nothing
would ever correct it. would ever correct it. **No `watcher_last_fired_at` either, deliberately removed** — see
`command_hq_watcher_fires` below; the owner's call, once cost tracking made clear that a single overwritten
timestamp was losing information a real table should hold instead.
### `command_hq_watcher_fires` — one row per trigger
Added when cost tracking surfaced a real gap: the ORIGINAL design cached "last fired" as a single column on
the commander row, which meant every new fire destroyed the previous one's timestamp. Fine for "when did it
last fire", useless for "how many times, and what did each firing cost" — which is exactly what the owner
asked for. So this became a real table instead of a column, and the commander row lost its cache entirely
(the owner's call: derive `MAX(fired_at)` at read time rather than keep a copy that could only ever describe
the same table this row also has to insert into).
| column | notes |
| ---------------- | ------------------------------------------ |
| `session_row_id` | FK to `command_hq_sessions.id` |
| `task_id` | which watcher task fired |
| `fired_at` | the only timestamp this row exists to hold |
### `command_hq_turns` — the cost ledger
One row per completed turn (every `result` message a commander's session produces, not only the ones that
follow a fire — the setup conversation before a watcher is ever armed costs something too).
| column | notes |
| ---------------- | ------------------------------------------------------------------------------------------ |
| `session_row_id` | FK to `command_hq_sessions.id` |
| `fire_id` | FK to `command_hq_watcher_fires.id`, nullable — null for turns before the first-ever arm |
| `input_tokens` | |
| `output_tokens` | |
| `total_usd` | `doublePrecision` — a monitoring number, not a billing ledger; see the schema file for why |
| `created_at` | |
**Nothing is pre-summed.** Running total, cost over time, cost per trigger and turn count are all queries
over this table — `getCommandHqCostSummary` (one `SUM`/`COUNT`), `listCommandHqTurns` (the raw series a
client plots), grouped by `fire_id` for per-trigger. Turn count in particular needed no column at all:
`COUNT(*)` on this table already answers it, the same "derive it" call the owner made for `watcher_armed_at`
and the dropped `watcher_last_fired_at` — raised explicitly when the schema was being designed, not assumed.
### `command_hq_categories` ### `command_hq_categories`
@@ -286,14 +350,19 @@ an independent record of what sessions exist. Consequences the owner confirmed e
- A session **never moves the other direction**. There is no unfile path, and none is planned — a commander - A session **never moves the other direction**. There is no unfile path, and none is planned — a commander
is a commander for life, and (given commanders live in their own cwd, never a `/chat` cwd) the two spaces is a commander for life, and (given commanders live in their own cwd, never a `/chat` cwd) the two spaces
never actually overlap. never actually overlap.
- **`DELETE /commanders/:sessionId`, added 2026-08-18, is not an unfile path — it is real deletion**, and
deliberately does not conflict with the bullet above: the commander does not become a `/chat` session, it
stops existing. `deleteClaudeSession` (the SAME function `DELETE /chat/sessions/:id` calls) removes the
transcript — a `/clear` chain and all — and `deleteCommandHqSession` removes the row. Once the confirm
step lands, this is not recoverable: there is no undo, no trash, and the row disappearing is a side
effect of the transcript being gone, not the other way around.
## Built on, unchanged ## Built on, mostly unchanged
Everything about _how a background job runs_ is out of scope — this only adds a place to browse, name and Everything about _how a background job runs_ was out of scope for the original build — a place to browse,
categorise the sessions the existing system produces. In particular, unmodified: name and categorise the sessions the existing system produces. That held until real testing found the
mechanism itself wanting; see "The watcher loop" below for what changed and why. Otherwise, unmodified:
- The Background Job dialog and `buildBackgroundJobPrompt` — Command vs Prompt, the watch/stop-on-fire
rules, "run until stopped" vs "run for N minutes" (both prose in the prompt, no server-side timer).
- The whole lifecycle: `run_in_background`/`Monitor`, `task:started`/`task:notification`, - The whole lifecycle: `run_in_background`/`Monitor`, `task:started`/`task:notification`,
`chat_session_events` as the durable log, `collectRunningTasks` as the fold, the tray. `chat_session_events` as the durable log, `collectRunningTasks` as the fold, the tray.
- Stop/pause/restart routes under `/chat/tasks/:id`. - Stop/pause/restart routes under `/chat/tasks/:id`.
@@ -301,6 +370,162 @@ categorise the sessions the existing system produces. In particular, unmodified:
- `officer-notify` — assume it works and is wired up separately; do not build a parallel notification path - `officer-notify` — assume it works and is wired up separately; do not build a parallel notification path
for this feature. for this feature.
## The watcher loop — exit, act, drain, re-arm
`COMMS/command-hq/watchers-what-broke.md` and `watcher-loop-proposal.md`, both 2026-08-18: the mobile side
actually ran a watcher (poll `git ls-remote`, react to each push) through the dialog, and the mechanism
broke in ways no amount of reading the code would have found — two watchers alive at once because a new
one was armed before the old was reaped, a watcher firing on the agent's own push to the branch it was
watching, "keep going" silently meaning the agent would never see a firing again because a background
process reaches the model only when it EXITS. The owner's call, confirmed 2026-08-18: **implement the
proposal as written, before the first real tests** — this governs the agent's behaviour generally, not
only Command HQ, so it is not optional groundwork the way most of this document is.
**The re-arm mechanism, settled 2026-08-18, after two false starts recorded here rather than quietly
fixed:**
1. First instinct: the PLATFORM silently respawns the watch command when it fires, no agent turn involved.
Wrong — the SDK only ever notifies the agent about a process it is itself tracking via `Monitor`. A
platform-side respawn exists outside that tracking, so it would fire once, work, and then go dark
forever — the agent is never told about the second fire. Not a cost tradeoff, a dead end.
2. Second instinct: keep the agent in the loop, but have the PLATFORM drive it — detect the acting turn's
`result` and send a synthetic follow-up message telling the agent to re-arm. Technically correct, but
costs a real extra turn every single cycle, and the owner pushed back on that cost before it was built:
an actual multi-hour, real-world watcher (the mobile side's own, on this repo, this session) had
already been running with the agent re-arming ITSELF, same turn, for hours — zero failures. That is
hard evidence to override with a hypothetical reliability argument.
3. **What is actually built:** the agent re-arms itself, as the LAST action of the SAME turn that handles
the notification — no separate turn, no platform-side spawn. The platform's role is narrower than
either false start: it only WATCHES for this happening and can say so if it doesn't (see "Built" below)
— it does not drive the re-arm and does not respawn anything itself.
**The evidence for step 3 is stronger than either side's first write-up said, and it is worth recording
precisely rather than as "ran fine for a while" (`COMMS/command-hq/mobile-4.md`).** Both notes originally
described the mobile side's own same-turn watcher as having run "for hours, this session." Andre's own
count, checked directly: **at least 10–15 runs, spanning before and after the current mechanism existed,
zero failures.** That is a different class of claim than "it worked tonight" — it is evidence against
_revisiting_ the decision, not just evidence for having made it. Worth stating why that distinction
matters: same-turn self-re-arm looks fragile on paper (it depends on the model correctly doing one specific
thing as the last step of every turn, unsupervised, forever), so the instinct on reading the design cold is
to add a safety net — a supervisor, a timeout, a platform-side check that re-arm actually happened. The
record here is that a dozen-plus real runs across two different surrounding mechanisms never once needed
one. It is also why the platform's role stays _noticing_ (`watcherArmed && !running`, "Deliberately not
built" below) rather than _driving_: noticing is the right amount of machinery for a failure with zero
observed instances, and building more than that would be solving a problem the evidence says does not
occur.
**Built:**
- **A third dialog option**, `FireBehavior = 'stop' | 'handoff' | 'keep-going'` (was a `stopOnFire`
boolean). `handoff` — "Exit and hand back to the agent, then re-arm" — carries the contract
(`handoffContract` in `background-job-prompt.ts`) verbatim: _"When the condition fires you will be handed
back, with the change. Do the work — and before you end this turn, start the SAME watch again yourself,
exactly as you did the first time: same condition, same command. That is the whole loop, every time: act,
then re-arm, in the one turn."_ The closing "IF IT EXITS" section is rewritten for `handoff` too, so it
does not contradict rule 7 by telling the model an exit means stay-stopped.
- **`isCommander`, gating the promise to where it can be kept.** The proposal frames this entirely in terms
of commanders — "a commander already has a row," arming state's only home — and outside one there is
nowhere to persist "this should keep re-arming itself," so nothing would fulfil the promise. Rather than
make it anyway, `BackgroundJobDialog` only offers `handoff` when `isCommander` is true, defaulting to it
there and to the pre-existing `stop` everywhere else. **Not yet wired to `true` anywhere** — Command HQ's
own composer, which would set it, does not exist yet (web) or does not use this dialog (mobile has its
own). Every existing `/chat` caller is unaffected: two options, `stop` default, exactly as before tonight.
- **Stopped no longer reads as failed.** `watchers-what-broke.md` §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, at the one place it can be fixed cheaply: `stopTask` in
`BackgroundTaskTray.tsx` now also calls `markStopRequested(taskId)`
(`background-job-origin.ts`), and `useChat.ts`'s `task:notification` handler overrides the displayed
status to `stopped` when a stop was just requested for that id, regardless of what the harness reported.
Known gap: this is in-memory client state, so it does not survive a page refresh between clicking Stop
and the notification arriving — an acceptable, narrow window, not attempted to be closed tonight.
- **`command_hq_sessions` gained `watcherArmed`, `watcherTaskId`, `watcherArmedAt`, `watcherStoppedAt`.**
Unlike `running`, `watcherArmed` is allowed to be stored — it is the owner's INTENT ("keep re-arming
this"), not a live process fact the platform is reporting on, so nothing external can silently falsify it
the way a sidecar restart falsifies `running`. `watcherTaskId` is closer to that rejected shape and
accepts the same known gap `running` already lives with — see the schema file's docstring.
`watcherArmedAt`/`watcherStoppedAt` are the current-run boundary pair, added a turn later during the
cost-tracking pass once "since when has this been going" was a real question — see "Data model" above.
There is no `watcherLastFiredAt` column any more: that fact moved into `command_hq_watcher_fires` (its own
table, one row per trigger) once cost tracking needed per-fire attribution, not a single overwritten
timestamp. `armCommandHqWatcher` / `recordCommandHqWatcherFired` / `disarmCommandHqWatcher` /
`recordCommandHqTurn` in `queries.ts`; exposed read-only on `GET /commanders` and
`GET /commanders/:sessionId` (`watcherLastFiredAt` survives as a name in the JSON response — it is just
computed there, `MAX(fired_at)`, not read off a column), and `PATCH /commanders/:sessionId` accepts
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
running turn has).
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant → `startClaudeTurn`'s
`msg.armWatcher`, which sets `session.pendingArmWatcher = true` — on the SESSION object, not captured by
`createMessageHandler`'s closure, because the dialog is routinely submitted turns after that closure was
built (the session-scoped event subscription wires ONCE, on the session's first-ever turn) and a value
captured then would never see a later arm.
- **The noticing, in `createMessageHandler` (`websocket.ts`).** On `task:started`: if `pendingArmWatcher`
is set, consume it (one-shot, the same arm/claim shape `background-job-origin.ts` already uses
client-side for the identical problem) and call `armCommandHqWatcher` — this is what turns "some task
started" into "this is the watcher" in the first place. If the commander is ALREADY armed instead, a new
task starting is what the agent's own self-re-arm produces, so the tracked `watcherTaskId` is simply
updated to it — the platform is not driving anything here, only keeping up with what the agent already
did. On `task:notification`: if it matches the tracked `watcherTaskId` for an armed commander, record the
fire — a new row in `command_hq_watcher_fires`, not a column write. Both read `getCommandHqSession` live
rather than trusting any closure-local state, for the same reason as above. On `result`: every completed
turn writes a row to `command_hq_turns`, attributed to the commander's most recent fire (`fire_id`, null
before the first-ever arm) — see "Data model" for why nothing here is pre-summed.
- **Known, accepted imprecision:** the "already armed → treat the next task:started as the re-arm" rule
cannot tell a genuine self-re-arm apart from an unrelated background task the agent happens to start in
the same commander conversation. A commander is one watcher by design, so this is a narrow gap rather
than the common case, and not attempted to be closed tonight.
Suggested order from the proposal, all four items now done: (1) prompt + dialog, (2) stopped-vs-died, (3)
the re-arm mechanism itself — landed as agent-driven, same-turn, after two rejected designs recorded above
— (4) arming state and the plumbing that watches for it.
**Deliberately not built: the platform verifying re-arm happened.** Raised as a possible cheap addition —
since the platform can already derive last-fired (`MAX(fired_at)` in `command_hq_watcher_fires`) and already
computes `running` from the same fold `GET /commanders` uses for every card, a client could flag "fired N
ago, nothing has run since" without any new mechanism, purely by reading what already exists. Not built
tonight; noted here so it is not lost, not because it was rejected.
**Auto-naming from the watcher's own description, added 2026-08-18 after the web frontend surfaced why it
matters.** A fresh commander's title is whatever the first message was — the mechanics preamble, truncated,
when that first message is a `handoff` background job. Expected, not a bug: that IS the first message, and
Claude Code names sessions from it the same way it always has (see "Naming" above). But by the time the
watcher's task actually STARTS, the harness has already given it a real description — `msg.description` on
`task:started`, "Watch google.com reachability" for a ping watcher — and that reads far better than the
preamble ever will. So the exact moment `pendingArmWatcher` is consumed (the FIRST arm, never a self-re-arm
— see "known, accepted imprecision" above for why that distinction is already load-bearing here) now also
calls `renameClaudeSession` with that description, resolving a fresh `ChatIdentity` via `resolveHomeDir`
for the filesystem call. Best-effort, like everything else in this block: a failure is logged and does not
touch `watcherArmed`.
**Known gap, not closed:** the detail panel's title bar is fetched once on mount and does not live-refresh,
so a commander open in the panel at the moment this fires keeps showing the old title until the next
navigation or reload. The grid's cards do not have this problem — `GET /commanders` is already polled every
15s. Flagged rather than fixed; worth a `refetch()` hook if it turns out to matter in practice.
**"(no output yet)" on that same google.com watcher, traced to the actual running process on this
machine rather than guessed at.** The tray's live-log-tail (`BackgroundTaskTray` → `useTaskDetail` →
`GET /chat/tasks/:id` → `loadBackgroundTask`/`tailFile`) is a general `/chat` feature Command HQ gets for
free — nothing to build there, and it was already working correctly. The task's `.output` file was
genuinely empty: the agent had written `timeout 8 ping -c 1 -W 5 google.com >/dev/null 2>&1`, silencing
`ping`'s own output entirely and only ever `echo`ing on a FAILED ping — correct per "let me know if it goes
down," but it means a healthy watcher shows nothing at all, indistinguishable at a glance from a stuck one.
Fixed at the source rather than in the tray: `background-job-prompt.ts` gained an unconditional rule (every
job, not only watchers — number 4, 5 or 8 depending on which rules already preceded it in that particular
prompt) telling the agent not to discard a command's own output. The tray already shows whatever reaches
the log file live; the gap was that jobs were routinely told, by habit rather than by this prompt, to send
that output to `/dev/null`.
**A real bug, caught by tracing rather than by the compiler, worth stating plainly.** When the cost-tracking
schema changed `recordCommandHqWatcherFired`'s signature from `(userId, sessionId)` to `(sessionRowId,
taskId)`, the ONE existing call site was not updated in the same pass — and `bunx tsgo` stayed clean, because
`userId: number` and `transcriptId: string` still satisfy `sessionRowId: number, taskId: string`
structurally. It would have written a fire row for a commander keyed on the wrong id entirely (the caller's
OWN user id, not the row it belonged to). Found by re-reading every call site the schema change touched
after the fact, not by any tool — worth remembering the next time a signature changes shape rather than
just its types: a compiler that stays quiet is not the same as a diff that is correct.
## Later — explicitly deferred, not forgotten ## Later — explicitly deferred, not forgotten
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of
@@ -317,9 +542,17 @@ scope for tonight."_
says running" against "pid check says running" is what turns a false "running" into a third, honest state says running" against "pid check says running" is what turns a false "running" into a third, honest state
("vanished"). Cost matters here: `fuser` is a subprocess spawn per task, so this is a detail-pane check ("vanished"). Cost matters here: `fuser` is a subprocess spawn per task, so this is a detail-pane check
or a batched sweep on load, never a per-card check on every render of the grid. or a batched sweep on load, never a per-card check on every render of the grid.
- **No durable job record.** A commander's only record of its own history is `chat_session_events`, which is - **No durable job record — REVISED.** True when written; no longer true for cost. The owner still does not
swept after 7 days. The owner does not care how many times a watcher fired or what each firing found — the care how many times a watcher fired or WHAT each firing found (the chat transcript remains the only record
chat transcript being the record is intentional, not a gap to fill. of content, and that is intentional), but does want per-turn token/dollar cost durably recorded —
`command_hq_turns` and `command_hq_watcher_fires`, added same session. See "The watcher loop" and the data
model section above.
- **Cost-based limits — explicitly out of scope for tonight, said directly by the owner.** "If this agent
reaches a certain cost in tokens, stop it, or compact the conversation and resend, or clear and resend."
Not built, and deliberately not designed around either: the ledger this session added
(`command_hq_turns`) is exactly the raw material a limit would need (`SUM` since a fire, or since armed),
so nothing here forecloses it — but which action, at what threshold, and whether it is a per-commander
setting or a global one, is all undecided.
- **Detail pane contents beyond "the conversation".** The owner asked for logs, status, and transcript - **Detail pane contents beyond "the conversation".** The owner asked for logs, status, and transcript
together in a split view; scoped down to "click a card, see its details" for v1, with the specifics together in a split view; scoped down to "click a card, see its details" for v1, with the specifics
(separate log tail vs transcript vs live status) left open. `/chat/sessions/:id` (transcript) and (separate log tail vs transcript vs live status) left open. `/chat/sessions/:id` (transcript) and
@@ -347,5 +580,8 @@ scope for tonight."_
## Still open ## Still open
Nothing blocking as of this revision. `POST /command-hq/commanders/new` is built — see "How a commander is Nothing blocking as of this revision. `POST /command-hq/commanders/new` is built — see "How a commander is
created" — with `prompt` required, confirmed with the owner 2026-08-18. Neither path (web or mobile) has created" — with `prompt` required, confirmed with the owner 2026-08-18. The web frontend v1 is built too
been exercised against a running server yet; that is the next real test, not a design question. (see the status line at the top). **Neither client has actually been clicked through against a running
server yet** — that is the next real test, not a design question, and it is the thing most likely to turn
up something this file gets wrong: the composer's redirect-on-real-id, the watcher-disarm button, the
category-race PATCH, none of them have run once outside a typecheck.
+159
View File
@@ -0,0 +1,159 @@
# Recall over your own correspondence
**Status: DESIGN, NOT BUILT.** Sketched 2026-08-20, for the email plugin.
## The goal, which is not the technology
Someone is on a phone call with their ex-wife. She says something about the weekend arrangement. He has
eight years of email with her, and he needs the fact **now** — not after a search, not after reading
twenty messages.
That is the whole requirement. Everything below is in service of it, and any part of this that does not
make that moment faster or more accurate is decoration.
The user will never write a query. They ask the agent a question in their own words, and the agent picks
its tools. So the design question is not "what can the user search" — it is **"what does the agent have
to work with, and which of its answers can be trusted"**.
## Two mechanisms, because there are two kinds of question
The examples that prompted this split cleanly, and they need different machinery:
| the question | what it needs |
| ---------------------------------------------------------------- | ------------------------------------- |
| _"how many times did she ask me to break the weekend agreement"_ | **exhaustion** — every message judged |
| _"what did she say about the school run"_ | **retrieval** — the best few, fast |
```
COMPILE exhaustive, once, expensive → counting, timelines, "show me all of them"
RETRIEVE cheap, approximate, always → exploring, mid-call, "what did she say about…"
```
They are not competing designs. The agent should have both and choose.
### Counting cannot be done by searching
This is the load-bearing point, and it is the one most likely to be skipped.
An agent asked "how many times" will search, find some, and answer with a number. That number is bounded
by the phrasings it happened to think of. **"I found four" is indistinguishable from "there were nine"**,
and nothing in the output signals which it was.
Vector search does not fix this. Top-k also misses — it just misses differently. Ask for the top 20 and
the 21st is silently gone.
The only honest way to count is to judge every message once and store the verdict. Then counting is
`SELECT count(*)`: exact, instant, and re-askable a hundred ways without re-reading anything.
**A stored verdict is a judgement a model made about someone's divorce.** It records what it was asked,
which message it read, and when — so the answer can be inspected, disbelieved and redone. A number with
no provenance is worse than no number, because it will be quoted.
### Retrieval is where embeddings earn their place
Keyword search finds `school run`. It will never find it from _"the pickup arrangement"_ or _"when I
collect her on Fridays"_. Over eight years, the same thing is described twenty ways and never once in the
words you would search for.
An agent closes some of that gap by itself — it knows those are synonyms and can fire several searches.
But that is **guess-bounded**: it finds the phrasings it thought of, and cannot know the ones it did not.
Two things decide it in favour of embeddings anyway:
- **Latency.** Five keyword searches and twenty candidate reads is thirty seconds. On a live call that is
a lost moment. A vector query is milliseconds plus one read.
- **What comes later.** WhatsApp and audio transcripts share no vocabulary, schema or structure with
email. Embeddings collapse all of it into one space. ASR output is noisy in a way keyword search is
brittle against and embeddings are not.
**But we do not have WhatsApp or transcripts.** They shape exactly one decision — see the schema — and
nothing else. Build against email.
## It has to run locally
Every message passes through an embedding model. Doing that through an API means uploading eight years of
private correspondence to a third party.
For this content that is not a trade-off, it is a disqualification. **Local embedding is a requirement,
not a preference.**
Today there is no ollama and no embedding code on any of these machines. **That is the real new
dependency — not the database.**
## Not a vector database
`sqlite-vec` is an extension to the SQLite already in use. Same file as `emails.db`, no service, no
sidecar, no Docker, no port, nothing to supervise or back up separately.
For a plugin that already owns that file, this is **a table, not an architecture**. A separate vector
database would add an operational surface for a corpus of 19,000 rows, which is small — vector stores
begin earning their keep in the millions. What is being bought here is recall on paraphrase and speed on
a phone call, not scale.
## The genuinely hard part is chunking
Not the vectors. **Quoted reply chains.**
The same paragraph exists in forty messages of one thread. Embed naively and every query returns that
thread forty times, and it will feel worse than what exists today.
So: strip quoted history, thread properly, embed the new text of each message. `emails` already has
`thread_id`. This is the work.
Two other email-specific traps measured on the real 19,097-message store:
- **Bodies are ~0.5 GB of HTML.** Embed the text, not the markup.
- **`labels` is a comma-separated string**, and `read` is populated on 93 rows. Neither is a filter to
build on. See the `email-db` skill.
## Schema sketch
Deliberately **not** email-shaped. That is the one concession to a future that does not exist yet, and it
costs nothing now:
```
recall_chunks
id integer pk
source text -- 'email' today. 'whatsapp', 'transcript' later.
source_id text -- the message id in ITS store — no foreign key across sources
account text -- which mailbox/number this came from
thread_id text -- for dedupe and for showing context
author text -- who said it
said_at timestamp
text text -- quoted history stripped
embedding float[] -- sqlite-vec
recall_verdicts -- the COMPILE half
id integer pk
question text -- what the model was asked, verbatim
source text
source_id text
verdict boolean
reasoning text
model text -- which model, so a re-run is comparable
judged_at timestamp
```
Everything else assumes email until something else exists.
## Build order
1. **Chunking and dedupe**, with no embeddings at all. Strip quotes, thread, count what is left. If this
is wrong nothing after it can be right, and it is testable on its own.
2. **A local embedding model**, and a decision about which. The new dependency.
3. **`sqlite-vec` + backfill** of the existing corpus. One-off, slow, resumable.
4. **A retrieval tool** the agent can call, returning chunks with their message ids so it can go read the
original.
5. **The compile half** — a verdict table and a way to ask "judge every message against this question".
6. Multi-source, if and when WhatsApp or transcripts exist.
Steps 1 and 5 are useful without any of the others. Step 5 in particular answers the questions that
prompted this, and needs no vectors at all.
## Open questions
- **Which local model**, and how big. Quality against a laptop-or-server-sized footprint.
- **Re-embedding.** Changing model means re-embedding everything. Store which model produced each vector.
- **Retention and deletion.** A deleted email should lose its chunks and its verdicts. Nothing here
currently cascades.
- **Who can query it.** This is the most sensitive data in the platform, and it is per-account.
+269
View File
@@ -0,0 +1,269 @@
# Find My Device, on Officer
**Status: DESIGN, NOT BUILT.** Sketched 2026-08-19, the same day push notifications went in — which is
not a coincidence, because push is the piece that makes this possible at all.
## What this is, and what it will never be
**The pitch is not "replace Find My". It is "know where your devices are without Apple or Google being
the ones who know."** Location history on your own server, on your own tailnet, under your own retention
rule. That part is mostly assembly.
**The part that is genuinely impossible: offline finding.** Apple's magic is not the map. It is that a
lost iPhone with no network still broadcasts an encrypted identity over Bluetooth, and roughly a billion
strangers' devices relay it home. That is a crowd, and you do not have one. Nor does any self-hosted
system, ever.
So the honest boundary:
| | |
| --------------------- | ------------------------------------------------------------------ |
| Device has network | **works** — current position, on demand or on a schedule |
| Device is off-grid | **last known position and nothing more** |
| Device is powered off | **nothing.** Apple's is silicon-level; there is no software answer |
| Device is wiped | **nothing** |
A tracker that answers "where was it last, and when" covers the overwhelmingly common case — left in a
taxi, lost in the house, stolen and still online. It does not cover the case Find My is famous for.
Anyone building this should be clear about that before starting, because the gap is not a to-do item.
## What already exists
More than expected, which is why this is worth doing now rather than someday.
- **The wake channel.** You cannot reach a sleeping phone except through APNs/FCM. That is built,
`officer/core:*` codes and all, and a real device is registered. See `docs/push-notifications.md`.
- **A map.** `plugins/photos/web/MapSection.tsx` — maplibre-gl with markers, clustering, bounds-fitting,
already working and already pinned to a version that behaves. Read its header comments before writing a
second map; they record two traps that cost someone a day.
- **Device presence, free.** offscale/headscale already reports `online` and `lastSeen` per node
(`plugins/offscale/sidecar/normalize.ts`). "Is my laptop awake, and when was it last seen" needs no new
code at all — and it covers laptops and desktops, which the phone-app approach never will.
- **The tailnet**, so a device reports home without anything being exposed publicly.
- **The hub app**, already installed, already registered, already receiving pushes.
What is missing is a table, an endpoint, some app code and a panel.
## Architecture
Two paths, and they are not the same shape.
```
REPORT (device → server, most of the time)
phone ──POST /api/find-my/report──→ platform ──→ device_locations
COMMAND (server → device, rarely)
panel ──→ officer-notify ──push──→ hub app ──→ acts, then REPORTs
```
**Everything of value flows on the report path.** The command path exists only for "where is it _right
now_", and it is the unreliable one — so the design must not depend on it.
### The report path
The device posts a fix. That is the whole thing. It works when the app is foregrounded, when iOS hands it
a background location update, and when a queued batch drains after a period offline.
**Batching matters.** A phone that has been off-grid for six hours has six hours of fixes. The endpoint
takes an array, and every fix carries its OWN timestamp from the device clock — which is why the schema
below separates `recorded_at` from `received_at`. Collapsing them loses the distinction between "you were
there at 14:00" and "the phone told me at 20:00", and that distinction is the entire value of a history.
Device clocks are wrong sometimes. Store both, trust neither blindly, and let the UI show `recorded_at`
while any "is this stale" logic uses the pair.
### The command path, and why it is the weak one
To make a sleeping phone report _now_, you push. The options, in order of preference:
1. **Silent push** (`content-available: 1`), app wakes, takes a fix, posts it. The right shape — and
**Apple throttles these and does not guarantee delivery.** Already established in
`docs/push-notifications.md`, and it is the reason silent pushes were rejected for notifications.
2. **A visible push the user taps.** Reliable, but requires a human at the other end, which defeats the
purpose when the phone is in a taxi.
3. **A Notification Service Extension taking the fix on delivery.** Attractive because the NSE runs on a
normal, reliable, high-priority push. **Unverified** — location access from an extension is possible
in principle but unusual, and nobody here has tried it. Worth an afternoon before committing.
**Design conclusion: treat on-demand as a bonus, never as the mechanism.** If the background reporting is
good, "where is it now" is answered by "where was it 4 minutes ago", and that is enough for almost every
real situation.
## Device identity — do NOT key this on the push token
The most likely serious mistake in this design, so it goes above the schema.
`push_devices` is unique on `(token, bundle_id)`, and **APNs tokens rotate**. A location history keyed to
a rotating token silently forks into two devices, and the split looks like the user bought a new phone.
The registration path is already an upsert precisely because tokens churn.
So a device needs a **stable identity generated once on the device at first launch** and persisted across
token rotations, app updates and re-registrations — a UUID in the keychain, not derived from anything
Apple gives you (`identifierForVendor` resets when all your apps are uninstalled).
That means a `devices` table that is NOT `push_devices`:
- `push_devices` answers "where do I send a push" — churny, plural per device, token-keyed.
- `devices` answers "which physical thing is this" — stable, one row per device, and what location,
battery and presence hang off.
A `push_devices.device_id` foreign key joins them. This also makes a device that has _never_ registered
for push (a laptop reporting over the tailnet) representable, which the push table alone cannot do.
## Schema sketch
```
devices
id serial
user_id fk users
device_uuid text -- generated on-device, stable forever. unique with user_id
name text -- "André's iPhone", user-editable
platform text -- ios | android | macos | linux
model text -- reported, cosmetic
created_at, last_seen_at
device_locations
id bigserial
device_id fk devices
latitude double precision
longitude double precision
accuracy_m real -- ALWAYS store it; a 3km fix rendered as a pin is a lie
altitude_m real nullable
speed_mps real nullable
heading_deg real nullable
battery_pct smallint nullable
is_charging boolean nullable
source text -- significant | ondemand | visit | manual | geofence
recorded_at timestamptz -- the DEVICE clock, when the fix was taken
received_at timestamptz -- the SERVER clock, when it arrived
```
Index on `(device_id, recorded_at desc)` — every query is "latest for this device" or "this device
between two times", and both want that order.
**`accuracy_m` is not optional.** iOS returns fixes with 3km accuracy when it is being cheap about it, and
a UI that draws those as a confident pin will send someone to the wrong street. Draw the circle.
**Battery belongs here**, not in a separate table. "Last seen at 4%" is often the single most useful fact
on the screen — it tells you whether to expect another fix at all.
## The platforms
### iOS
- **`Always` location authorization** plus the `location` background mode and
`allowsBackgroundLocationUpdates`. `When In Use` is not enough; a find-my that only works while you are
looking at it is not one.
- **Significant-change monitoring** is the floor: roughly cell-tower granularity, very cheap, survives app
termination, and iOS relaunches the app to deliver it. This should be the primary source.
- **The Visits API** is cheaper still and gives arrival/departure at meaningful places. Good for history,
useless for "it is moving right now".
- **Continuous GPS is not an option** for background use. It will eat the battery and iOS will fight you.
- The app is self-distributed, so App Store review of background location is not the obstacle it would
otherwise be.
### Android and GrapheneOS
**Easier, and there is a pleasing irony here.** The foreground service holding a tailnet socket — the
approach `docs/push-notifications.md` records as the _fallback_ for GrapheneOS having no Play Services —
is close to ideal for this. It is always connected, so it needs no push to be told to report, and the
persistent notification that made it unattractive for notifications is a normal thing for a location
service to have.
So on the phone where push is hardest, find-my is easiest. One foreground service can serve both.
### Laptops and desktops
Do not write a client. **headscale already knows** `online` and `lastSeen` per node, and the endpoint
addresses give a coarse network location. That is most of what anyone wants for a laptop, and it exists
today.
## Commands, beyond locating
Each needs app code — **the notification catalogue makes new notifications free, not new behaviours.**
- **Locate now** — the weak path above.
- **Play a sound.** Genuinely useful, and the "lost in the house" case is the most common of all. Needs an
audio session category that overrides the silent switch.
- **Mark as lost** — show a message and a contact number on the lock screen. Needs MDM on iOS.
- **Lock / erase.** Real MDM. Officer already serves iOS configuration profiles (`claimIosProfile` in
`api/dav/ios-profile`), so this is a project rather than an absurdity — but it is a project, and it puts
a device-wipe capability behind whatever authenticates to it. Think hard before building it.
## Retention, and the thing to decide first
**A location table on your own server is a complete movement record of your life.** Apple deliberately
cannot build one; you would be choosing to. That is a legitimate choice, and it is the whole point of
self-hosting — but it means the retention rule is a design decision, not a settings screen to add later.
Decide before the table exists:
- **A default retention window** with an actual delete job, not a documented intention. 30 days is a
reasonable starting point: enough to answer "where was I on Tuesday", short enough that a compromise is
not a biography.
- **A pause switch** that stops reporting, visible on the device, honest about what it does.
- **What a backup contains.** The Postgres backup now includes a movement history. Anyone who copies a
dump copies that.
- **Who can see whom.** Location is per-user and every query filters by `user_id` — but **the owner
bypasses every permission check by design** (`permissions/authorize.ts`). So on a family server the
owner can see a member's location, structurally, unless location queries are given an explicit
exception. That is a policy decision that has to be made deliberately, because the default falls out of
the existing architecture rather than out of anyone's intent.
That last one is the sharpest question in this document.
## Permissions
A new registry entry, `kind: 'app'` so it is grantable per role, with `personal` covering the report
endpoint so a member's own device can post its own fixes at read level — the same shape (and the same
trap) as `notify`, where the `personal` paths must be the real post-prefix paths. See
`permissions/registry.ts` and the `notify` regression test.
`assertPermissionTotality` will refuse to boot without the entry, which is the intended behaviour.
## The web surface
Per the frontend route conventions in `CLAUDE.md`: a `/find-my` route rendering `WorkspaceView`, with
panels rather than a standalone screen.
- **Device list** — rows are real `<Link>`s to `/find-my?selected=<deviceId>`, never onClick closures.
- **Map panel** — reads `?selected` from the URL, not from a channel. Reuse the maplibre setup from
`plugins/photos/web/MapSection.tsx`.
- **History/timeline panel** — a date range, also in the URL.
Selection is the URL. `docs/navigation-audit.md` is the authority and names the anti-pattern this must
avoid.
## Core or plugin
**Probably a plugin**, unlike notify. The argument for notify being core was that its producers are the
platform's own — the job queue, an agent turn ending. Nothing in the platform produces a location. It is a
self-contained feature with its own table, its own panel and its own app support, which is the plugin
shape exactly.
The counter-argument is that it needs a stable `devices` table that other things will want (push, presence,
per-device settings), and a core concept living in a plugin is how the notify mess started. **Split it:**
`devices` is core, find-my is a plugin that hangs off it.
## Build order
1. **`devices` table, core**, with the on-device UUID, and `push_devices.device_id` joined to it. This is
worth doing even if find-my never ships — the push registry currently has no stable device identity.
2. **Report endpoint + `device_locations`.** Batched, both timestamps, accuracy required.
3. **Hub app: significant-change reporting.** The floor, and on its own already a working find-my.
4. **The panel** — device list, map, last-known position.
5. **Retention job.** Before real data accumulates, not after.
6. **Play a sound.** The highest value-per-effort command by a distance.
7. **On-demand locate**, accepting it is best-effort.
8. Android foreground service, folded in with the push fallback if that is ever built.
9. MDM tier — separately, and only if genuinely wanted.
## Open questions
- **Owner visibility of a member's location.** The default falls out of the permission architecture rather
than out of intent. Decide it explicitly.
- **Retention default**, and whether it is per-device.
- **Can an NSE take a location fix?** Would make on-demand reliable. Unverified, worth an afternoon.
- **Geofences** — "tell me when the phone leaves the house" is cheap on both platforms and is arguably
more useful than on-demand locating. Not in the build order above; it might deserve to be.
+400
View File
@@ -0,0 +1,400 @@
# Localization
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
This is an execution plan. It is written to be handed to an agent who has not had the conversation
that produced it, so it states the decisions and the reasons for them, not just the steps.
Every count here is a **measurement taken on 2026-08-23**, not a claim that stays true — the whole
point is for it to fall. Re-derive with `bun scripts/i18n/scan-hardcoded.ts` rather than trusting the
number beside it, and do not wire these into `docs:check`: a guard that fails when the count drops
would be a guard against progress.
---
## What this is
Every word the UI shows a person should come from a catalog keyed by locale, so the platform can be
read in a language other than English. Routes stay English (`/minesweeper`, `/chat`) — that is a URL
decision, deferred, and nothing here depends on it.
The scope is **the web UI only**. Not the server, not the plugins, not email or push. See D1 for what
the server does instead, which is the one decision that reaches outside this boundary.
---
## What exists today, and what it cannot do
**Updated 2026-08-23, after phases 0, 1 and 2.** This section described the pre-phase-0 world for long
enough that the next reader would have started from a false picture — bare `en.json`, 46 keys, three
components. All of that is now history and is kept below only as the "before" column, because the shape of
the gap is the argument for the plan and deleting it would leave the phases looking arbitrary.
| | before phase 0 | now |
| -------------------------- | ---------------------- | --------------------------------------------- |
| catalogs | `en.json`, `pt.json` | `en-GB.json`, `pt-PT.json` per workspace |
| keys | 46 | **581** — 538 `app.*`, 43 `ui.*` |
| workspaces with a catalog | 1 (`officer-web`) | 3 (`officer-web`, `components`, `officerdev`) |
| components using it | 3 | all of `officer-web` and `components` |
| plural rules | none | `Intl.PluralRules`, per locale |
| dates and numbers | browser locale | `useFormat()`, the account's locale |
| a missing key | rendered its dot-path | falls back to English, warns in dev |
| locales offered vs shipped | ~20 offered, 2 existed | only shipped locales are offered |
| parity enforcement | none | **a gap fails the build** |
Still standing from the original description, and still true: the package is hand-rolled, has no npm
dependency, and gives type-safe keys — a wrong key is a typecheck failure, which is worth more than it
sounds when one agent is editing hundreds of files.
**What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
wired into the bridge, and empty.
Two things learned doing phases 1 and 2 that the plan did not anticipate:
- **`TranslationKeys` walked into a plural object**, so `t('key', { count })` — the entire point of D7 —
did not compile while `t('key.one')` did. The documented feature was uncompilable and the workaround was
not, which no test would ever have noticed because phase 1 contained no plurals. Fixed with type-level
assertions.
- **The typed wrappers rebuilt `t` on every render**, which is the trap `CLAUDE.md` already records against
`useClient()` and the Jellyfin player. It matters the moment `t` lands in a `useMemo` dependency list,
which phase 2 needed immediately. Memoised.
**A shape this format cannot express:** a sentence with an inline link. Splitting around the anchor gives a
key whose Portuguese value is `"."`. The cheap fix is rephrasing so the link falls at a clause boundary;
there is no node-level interpolation helper and adding one is a design decision, not a patch. Worth
raising if a phase turns out to be full of them rather than absorbing it forty times.
### The size of the remaining work
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
| area | files | occurrences |
| --------------------- | ------- | ----------- |
| `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 |
| **total** | **436** | **1170** |
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
That is a floor, not a ceiling. The scanner cannot see multi-line JSX, ternaries inside JSX, strings
passed down as props, or anything a plugin renders. Budget for **1200–1500 keys**.
---
## Decisions
### D1. The web UI only — and the server returns codes, not prose
The server should **never emit user-facing prose**. It returns a stable machine-readable code; the UI
owns every word a person reads and localizes it like any other string.
```jsonc
// not this
{ "error": "The Minesweeper session is not running" }
// this
{ "code": "MINESWEEPER_NOT_RUNNING" }
```
This is better than translating the server, for reasons that have nothing to do with translation. A
code maps to exactly one failure site, so it is greppable and a support conversation has something
precise to quote. Prose drifts; a code does not. And the server stops needing to know who is reading.
- **Phase 1 establishes the convention** so new code stops adding debt: new error paths emit codes,
and the catalog gets an `errors.*` namespace.
- **Phase 2 migrates the existing ~53** prose strings in `src/servers`. Not in this plan's scope.
- **Unmapped code** renders a generic localized line **with the code visible**. Never a blank, never
a raw key.
**The trap, and it is a real one: a code must not leak more than the prose it replaces.** Where a
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
the prose was careful not to be. When in doubt, one code for one _response_, not one per branch.
### D2. Regional locales, BCP 47, and every catalog is complete
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
it is the owner's own language, so getting it wrong is felt immediately.
**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
every key the UI has, translated for that locale, or it does not ship.
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
carried the shared bulk and a regional file carried only the differences. That is the conventional design
and it was rejected for a product reason that outranks the engineering one:
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
variant's words.
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
by the test rather than by a fallback quietly papering over a gap.
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
base-versus-overlay merge order, and no question about which file a given string lives in.
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
over a database migration — a stale bare code should resolve, not fail.
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
the other.
### D3. RTL — prepare, do not ship
No right-to-left locale ships in this phase. But since 436 files are being opened anyway, they get
opened once:
- **CSS logical properties** as components are touched — `margin-inline-start`, `padding-inline`,
`text-align: start` — instead of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this.
- **`dir` on `<html>`**, driven by the locale, wired now and set to `ltr` for every shipped locale.
The cost during a pass we are already making is close to nothing. The cost of a second pass over 436
files later is not. Hebrew stays in the picker but is not selectable until a catalog exists (see the
"twenty offered, two that exist" defect above — fix it by filtering the picker to shipped locales).
### D4. Extend the existing package. Do not adopt a library
`react-i18next`, `lingui` and `formatjs` are all better than what is here, and all of them are
dependencies. This repository froze its lockfile and hand-rolled a secret store rather than take
one, for reasons written down in `CLAUDE.md` → "Installs are frozen".
The gap is smaller than it looks, because the hard parts are in the runtime already: `Intl.PluralRules`,
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
lines of glue, listed in "What it cannot do yet".
Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms.
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
### D5. Keys, not English source text
`t('settings.profile.changePassword.title')`, never `t('Change Password')`.
Source-text keys (the gettext and lingui style) read nicely and mean an untranslated string still
renders. They also mean **editing an English word invalidates every translation of it**, and this
codebase already has typed keys — a wrong key is a compile error today, and that is not worth
trading away.
### D6. Catalogs live per workspace, merged at the bridge
Not one god file. Three:
```
src/apps/officer-web/locales/ → namespace app.*
src/workspaces/officerdev/src/locales/ → namespace panel.*
src/workspaces/components/locales/ → namespace ui.*
```
`I18nBridge` deep-merges them before handing the map to the provider. Each workspace exports its own
typed `useTranslation` the way `officer-web/lib/i18n.ts` does today.
Two reasons, and the second is the one that matters for execution. Strings belong next to the code
that renders them, so a panel stays self-contained. And **one 900-key `en.json` is a merge conflict
on every parallel edit** — with several agents or several days of work, a single file is a queue.
Namespaces also make an unqualified key impossible to place wrongly: `ui.*` is shared, so anything
under it must not mention a feature.
### D7. Plurals and formatting go through `Intl`
A plural value becomes an object; a string stays a string:
```jsonc
"panel.files.selected": { "one": "{{count}} file selected", "other": "{{count}} files selected" }
```
`t()` picks the branch with `new Intl.PluralRules(locale).select(count)` when `count` is passed.
Categories are per-locale — English and Portuguese use `one`/`other`, Arabic uses six — so the shape
must be an open record, never a two-field type.
A `useFormat()` hook returns `date`, `time`, `dateTime`, `number` and `relative`, each bound to the
active locale. **The ~23 `toLocale*` call sites move to it.** Left alone they follow the browser's
locale and quietly contradict the rest of the UI.
### D8. A missing key falls back to English and is loud in dev
Never render a dot-path to a person. Resolution walks the chain, ends at `en`, and only if English is
missing too does it render the key — which at that point is a bug the parity test below should have
caught.
In dev, a miss logs once per key with the key and the locale. In production it is silent.
---
## The key naming convention
Three segments, sometimes four. **Name the role, never the text.**
```
<namespace>.<area>.<component>.<element>
app.settings.profile.changePassword.title
panel.files.contextMenu.rename
panel.chat.composer.placeholder
ui.button.cancel
app.errors.MINESWEEPER_NOT_RUNNING
```
- `namespace` — `app` / `panel` / `ui`, per D6.
- `area` — the route or panel it belongs to; matches the directory.
- `component` — the file, lowercased.
- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
Rules that keep 1200 keys navigable:
- **Never key by the English words.** `ui.button.save`, not `ui.button.saveText` and never
`ui.button.Save`.
- **Never build a key at runtime.** `t(\`panel.${kind}.title\`)` defeats both the type checker and
the extractor. Use an explicit map from the dynamic value to literal keys.
- **`ui.*` may not name a feature.** If a shared component needs feature words, the caller passes
them as a prop.
- **One key per meaning, not per occurrence.** "Cancel" in twelve dialogs is `ui.button.cancel` once.
But a word that is the same in English and different elsewhere — "Post" the noun and the verb —
gets two keys. When unsure, split: merging later is safe, splitting later is not.
---
## What must never be translated
Getting this wrong is worse than missing a string, because it corrupts data or breaks a contract.
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
account language, not by ours. Translating them sends somebody hunting for "Ativar" in a console that
says "Enable".
So translate the connective prose around them and leave the quoted label in English. Do not key it with
identical values in both catalogs either — that is a key whose two sides can never legitimately differ,
which is a maintenance cost bought for nothing.
Decided 2026-08-23 on `GoogleOAuthConfig.tsx`, where 55 of the file's 67 strings are of this kind.
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
names, database values.
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
reading a terminal at 4am, and they should stay greppable in one language.
- **"Officer"**, and product names generally.
---
## Execution
Four phases. Each is a commit that leaves the tree green — the app must run in English at every point,
because a half-converted UI is unreviewable.
### Phase 0 — infrastructure, no visible change
Everything in "What it cannot do yet". Nothing in this phase touches a component.
1. `resolve.ts`: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).
2. `I18nProvider`: accept a merged map; expose `useFormat()`.
3. `I18nBridge`: deep-merge the three catalogs (D6); set `dir` on `<html>` (D3).
4. `Languages.tsx`: regional tags, and **filter the picker to locales that actually exist**.
5. Settings migration: widen stored bare codes on read.
6. Scaffold `locales/` in `officerdev` and `components`, each with its typed hook.
7. Land the tooling below.
Phase 0 is the whole plan's risk. It is also small, and it is the only part where a wrong decision is
expensive to reverse — review it before phase 1 starts.
### Phase 1 — shared components (91 files, ~47 strings)
Smallest, highest reuse, and it establishes `ui.*` before anything depends on it. Do this first so
phases 2 and 3 can reach for `ui.button.cancel` instead of inventing it twice.
### Phase 2 — the shell (132 files, ~556 strings)
`officer-web`: settings, dock, header, screens. Highest-traffic text and the part a reviewer will
look at first. Convert per directory, one commit per area.
### Phase 3 — the panels (213 files, ~567 strings)
`officerdev` apps. The largest surface and the most parallelizable, because panels barely share
strings. **One panel per commit** — this is where per-workspace catalogs earn their keep, but two
agents in the same panel will still conflict.
### Then: `pt-PT`
Only after English is fully keyed. Translating against a moving catalog is wasted work.
---
## Tooling and guardrails
Without these, 1200 keys rot. All three are small and belong in phase 0.
**`scripts/i18n/scan-hardcoded.ts`** — already written and committed. Reports every candidate
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
meter: the count goes down as work lands.
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
`bun docs:check` fails on a stale number.
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
it globally on day one would just be red forever.
**Also worth having:** an unused-key report. Keys outlive the components that used them, and nothing
else will ever notice.
---
## Verification
- `bunx tsgo` clean. A wrong key is a type error, so this is the primary check.
**Note for this machine:** `earlyoom` (`-r 3600`) SIGTERMs `tsgo` under memory pressure — it dies
around 15s with `Command failed`. That is not a type error. Free memory or scope the check.
- `bun test` — parity test green for every shipped locale.
- `bun scripts/i18n/scan-hardcoded.ts` — count strictly decreasing per phase.
- By hand: switch the locale to `pt-PT` and walk the converted area. Untranslated strings should read
as **English, never as dot-paths**.
- Check a plural at 0, 1 and 2, and a date, in both locales. These are the two things that look fine
in English and wrong everywhere else.
---
## Open questions
1. **Routes.** Deferred deliberately. Nothing here depends on it, and localized URLs bring
canonicalization and redirect questions that are not worth answering yet.
2. **Who translates?** Machine translation for `pt-PT` and `pt-BR` reviewed by the owner is the
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
manifest. Their strings are not counted above.
**One constraint that contract has to answer, found while converting the shared components
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
provider-absent fallback inside the `components` package was considered and rejected: it would have
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
+112
View File
@@ -0,0 +1,112 @@
# Notifications from more than one server
**Design, not built.** Written 2026-08-21, for the single-sign-on work.
The good news first, because it is the part that needed checking and it holds: **one Officer app can
receive notifications from every server you are signed into, and almost nothing has to change to allow
it.** One field is missing. This is what it is and why it matters more than it looks.
## Why it works at all
An APNs auth key (`.p8`) belongs to the **developer account**, not to a machine. There is no server
binding in it. So every Officer install can hold the same key, mint its own provider token from it, and
push to the same app.
The device token belongs to the **app**, not to a server either. One Officer app has one token, whichever
servers it talks to.
So N servers pushing to one phone needs no coordination between them. Each just needs the token, which the
app posts when it signs in — which the multi-server manager is doing anyway.
Two facts about the shared key, neither blocking:
- **Apple allows 2 active auth keys per account.** Sharing one across servers is the intended pattern;
one key per server is not an option past two.
- **Revoking it kills push everywhere**, with no way to tell which machine leaked it. That is the real
cost of copying it to ten boxes, and the reason it belongs in each one's secret store rather than
in `.env`.
## The missing field
A payload today:
```json
{
"aps": { "alert": { "title": "Officer" }, "thread-id": "chat:abc", "mutable-content": 1 },
"officer": { "c": "officer/core:chat.turn-ended", "id": 42, "mv": 1873 }
}
```
There is no origin. With one server that is correct and minimal. With five it breaks three things, and
only the first is obvious.
**1. The deep link opens the wrong thing.** `officer-chat://session/{id}` — that id is meaningful only on
the server that issued it. The app has no way to know which, so it picks one. Sometimes it opens nothing;
sometimes it opens somebody else's conversation with a matching id, which is worse.
**2. `mv` starts a resync fight.** `mv` is a content hash of **that server's** catalogue, and servers with
different plugins have genuinely different catalogues. The client's rule is `mv !== version → resync`.
With one stored catalogue and five servers, every push from server B invalidates the copy synced from
server A, and the app resyncs forever, always against whichever pushed last.
This is the one that would look like a mysterious battery and data problem rather than a bug.
**3. Threading collapses across servers.** `chat:{id}` groups per conversation. Two servers' ids
coinciding is unlikely with UUIDs and not impossible, and "these two unrelated chats are one banner" is a
confusing failure to diagnose.
## The change
**A server identifier in the `officer` block**, and the app keeping **one catalogue per server** keyed on
it:
```json
"officer": { "s": "<server-id>", "c": "officer/core:chat.turn-ended", "id": 42, "mv": 1873 }
```
`mv` then means "version of that server's catalogue", threading becomes `<server>:chat:{id}`, and the deep
link resolves against a known server.
### Where the identifier comes from is an SSO decision, not a notifications one
This is the part to settle tomorrow, and it belongs to the sign-on design rather than to this file.
The multi-server manager already needs a key for each server — to store its API key against, to label it
in a switcher, to route a deep link. **Whatever that key is, notifications should adopt it.** Inventing a
second identity here means two ids for one server, drifting the first time somebody renames a host.
It has to survive a hostname change, be stable across reinstalls of the app, and not be a secret — it
travels in a push payload, which is the one place content is deliberately withheld. A server-generated
opaque id created at bootstrap fits; a URL does not, because URLs change.
## What stays per server, correctly
Worth stating so nobody tries to unify it:
- **`push_devices`** — each server holds its own registrations. The same phone appears once per server.
- **`chat_notification_prefs`** — muting a conversation is per server, because the conversation is.
- **The catalogue** — a server's codes come from its own plugins. Two installs with different plugins
legitimately have different vocabularies.
None of that wants a shared store. The app is what joins them, which is the same shape as the sign-on
work: the app holds the list, each server holds its own truth.
## Current state, verified 2026-08-21
| | alpha | officer-dev |
| ---------------------- | -------------- | ----------- |
| iOS devices registered | 2 | 0 |
| APNs credentials | partly present | absent |
So only alpha can push today, and notifications seen elsewhere are the in-app bell rather than push. That
is not a limitation of the design — it is two things absent on the other machines.
## Order of work
1. **Settle the server identifier** with the SSO design. Everything else waits on it, and it is a
half-hour conversation rather than a build.
2. Add `s` to the payload; keep it optional so an older app ignores it.
3. Per-server catalogue storage in the app, keyed on `s`. **This is the one that must land before a
second server starts pushing**, or the resync fight above begins.
4. Thread ids become `<server>:chat:{id}`.
5. Copy the `.p8` into the other servers' secret stores, and have the app register its token per server.
+91
View File
@@ -0,0 +1,91 @@
# Plugin dependency scope
**Built 2026-08-21.** A plugin declares WHO has to be able to run each host binary, and the platform
installs and checks accordingly.
## The bug
`os-deps.ts` probes a dependency by asking the owner's login shell where the binary is. That is the right
question for most plugins and the wrong one for some, and nothing distinguished them — a per-user answer
was reported as a fact about the machine.
It matters because `confined` plugins do their work as **the member's own Linux user**, in their own home.
A binary in the owner's `~/.local/bin` is not on that user's PATH, and reaching it means traversing the
owner's home directory.
On alpha it happened to work. `/home/pastilhas` is mode **751** — world-traversable, not readable — so an
absolute path resolved for members. Mode 750, which is the more usual default, would have broken every
member's binary with nothing reporting anything: the install would still say satisfied, because the
install only ever asked the owner.
Working by accident is the part worth fixing. Nobody chose 751.
## The field
```ts
osDependencies: [{ binary: 'iris', reason: '…', packages: {…}, scope: 'shared' }]
```
`owner` is the default and stays right for most things — anything an `execution` plugin uses runs as the
owner in the owner's home by definition. `shared` says every account with a Linux user must be able to run
it, which changes both the install target and the check.
An unrecognised value is a manifest error rather than a silent fallback to `owner`, because the fallback
is the permissive one.
## What changes when it is `shared`
**Where it is looked for.** System directories only — `/usr/local/bin`, `/usr/bin`, `/bin`, and the sbin
trio. Deliberately not the owner's login shell: that shell finds the copy in the owner's home and reports
the dependency satisfied, which is the exact false answer this exists to stop.
**What counts as reachable.** Two things, and checking only the first is how this hid:
- the file is world-executable (`0o001` — group is not enough, members are not in the owner's group)
- **every directory above it is traversable**, walked to the root
A binary at 755 inside a home at 700 is unreachable, and `stat` on the file alone says it is fine.
**Where a release binary is installed.** `/usr/local/bin`, via `sudo -n install -m 0755`. The download is
still staged in the owner's own space — fetching straight into a root-owned directory would mean running
the download as root, which is a bigger ask than moving the result. `-n` so a plugin install never blocks
on an invisible password prompt.
**What blocks an install.** A shared dependency that is present and working but unreachable gets its own
state, `unreachable`, and its own refusal — checked before everything else, because no package manager
fixes it. The binary is installed; what is wrong is where it lives. Reinstalling the package would report
success and change nothing.
## What this deliberately does not do
**It does not gate execution on a grant.** A binary in `/usr/local/bin` is runnable by any account with a
shell, whether or not they hold the plugin's permission. The kernel does not know about `role_permissions`
and never will.
That is the same boundary `ffmpeg` and everything else in `/usr/bin` already has, and it is the right one:
the plugin's **data** is gated by the permission system, its **executable** is not. For iris it is benign —
a member who runs it captures their own `$HOME` profile and their own sessions, which is the isolation the
wrapper already depends on.
If a binary must not merely be _reachable_ but _unusable_ without a grant, `scope` is not the mechanism.
That needs the plugin's own surface behind the permission gate, and the binary not on anyone's PATH.
## Iris
The motivating case, and still the only `shared` one. `PLUGIN.md` had already recorded it as open item 3 —
"the binary lands in the owner's `~/.local/bin`, and a confined member's agent cannot read it there" — but
that had not been connected to the probe being owner-scoped, so the install reported success either way.
`iris` and `iris-browser` are built by the sidecar rather than fetched, so declaring `scope: 'shared'`
makes the probe honest about who can run them; moving the sidecar's build output into `/usr/local/bin` is
the remaining half, and it is a change to the sidecar rather than to this.
## Status
| | |
| ----- | -------------------------------------------------------------------------- |
| Built | `scope` on a dependency, validated, defaulting to `owner` |
| Built | shared probing on system paths, with a traversal walk to the root |
| Built | shared release binaries installed to `/usr/local/bin` via `sudo -n` |
| Built | an unreachable shared dependency refuses the install, naming the directory |
| Open | iris's sidecar still builds into `~/.local/bin` — see above |
+140
View File
@@ -0,0 +1,140 @@
# Plugin route authorization
**Platform half built 2026-08-21.** The gate enforces `ownerOnly`, the manifest accepts it, and the proxy
forwards `X-Officer-Owner` / `X-Officer-Level`. **No plugin declares it yet** — jellyfin's `/_config` is
still reachable by anyone granted the plugin, and cannot be locked until the credential question at the
bottom of this file is answered.
The question: a plugin's **settings** routes should be reachable only by the owner, even when the plugin
itself is granted to other accounts. Jellyfin is the case that raised it — connecting a server, storing
its admin token and provisioning accounts is not something a Member should reach, but Jellyfin as a whole
is exactly the sort of plugin a Member should have.
## The shape we agreed
Authorization stays where the platform already puts it — the gate answers "may this account reach this
plugin, and at what level" — and the plugin decides what that means **inside** its own surface. That is
not a new principle. `src/servers/plugins/manifest.ts` already says it, in the comment refusing an
`ownerOnly` field on a permission:
> Every plugin permission is grantable, per role, at read or write … Finer visibility — whose rows a
> member sees, what a read means for this plugin's data — is the PLUGIN's business and lives in its own
> queries.
"Which of my routes are administrative" is the same kind of question as "whose rows does a read return".
It belongs to the plugin, because only the plugin knows that `/_config` is settings and `/_jf` is bytes.
So: **the platform tells the plugin who is asking; the plugin decides what they may do.**
### What gets passed, and what does not
Today the proxy sends exactly one fact (`src/servers/sidecar/create-proxy.ts`):
```
X-Officer-User: <id>
```
The extension is one or two more headers alongside it. Two points about how, both load-bearing:
**Pass derived facts, not the role name.** The instinct is to send `X-Officer-Role: Super Admin`. Don't.
Roles are the platform's vocabulary and they are not frozen — `Admin`, `Member`, `Developer` today, with
grants keyed on the role string in `role_permissions`. A plugin that branches on a role name gives every
plugin repository an opinion about that vocabulary, and renaming a role silently changes authorization
in code the platform does not own and cannot see.
Send instead what a plugin can act on without knowing anything:
```
X-Officer-User: <id>
X-Officer-Owner: 1 | 0 — is this the server owner
X-Officer-Level: none|read|write — this account's effective level for THIS plugin's permission
```
`isOwner` is the fact that is structurally stable: user id 1, pinned by a CHECK constraint, bypassing
every permission check. It will mean the same thing in a year. `Super Admin` might not.
A plugin that genuinely needs a third tier — "Admins may also do this" — should not get it by reading a
role. It should declare a second permission key and let the owner grant it. That keeps the answer to
"who may do this" on the permissions page, where the owner can see and change it, rather than compiled
into a plugin's `if`.
**Nothing may be trusted from the caller.** The proxy builds a fresh header object and copies a
three-item allowlist; it does not forward arbitrary inbound headers. That is why `X-Officer-User` is
safe to trust, and any new header inherits the same safety **only for as long as the allowlist stays an
allowlist**. If that loop is ever changed to a copy-everything-except, a client-supplied
`X-Officer-Owner: 1` becomes a privilege escalation against every plugin at once.
**Absent means denied.** A sidecar that does not see the headers — reached directly, an older platform,
a bug — must resolve to no privileges rather than to the owner. Same rule as `authorize.ts`, where every
catch returns deny.
## Where I disagree, and it is one point
Plugin-side enforcement has a failure mode the platform has already been bitten by, and it is worth
naming before it happens again rather than after.
`assertPermissionTotality` exists because a Member could 403 on `GET /api/tasks` and open
`/api/tasks/pipeline/ws` with a 101 in the same minute. The lesson written down at the time was
"**a patch does not survive the next door; refusing to boot does.**" Pure plugin-side authorization is
that same shape: jellyfin will check, because we are about to write the check. The next plugin's author
forgets one route, and nothing anywhere notices — no boot failure, no warning, no test.
So the recommendation is not "platform enforces instead". It is **declare in the manifest, enforce in the
proxy, and pass the facts to the sidecar as well**:
Built as a field on the permission rather than at the manifest root, following `readOnlyWrites` — both
are sub-paths written relative to the plugin's prefix with a rule attached, and one precedent is better
than two shapes:
```ts
// manifest.ts
permissions: [{ key: 'jellyfin', label: 'Jellyfin', description: '…', ownerOnly: ['/_config'] }],
```
- **Declared by the plugin**, because the plugin knows which of its paths are administrative.
- **Enforced by the platform**, in the proxy, before the request reaches the sidecar — so forgetting the
check inside the plugin is not a hole, and the rule holds for a route added later by someone who never
read this file.
- **Visible at install**, next to the permissions the owner already reviews, and greppable across every
plugin from one place.
- **Still passed to the sidecar**, because the plugin's own finer rules — whose rows, which fields — are
genuinely its business and cannot be expressed as a path prefix.
This does not contradict the `manifest.ts` comment above. That comment refuses to make a **permission**
owner-only, because a permission is a thing the owner delegates and one nobody can delegate is a
contradiction. A path prefix is not a grant; it is a statement that a route administers the plugin
rather than uses it.
## The frontend is a courtesy, not the lock
Hiding the Settings entry in the plugin's nav for a non-owner is worth doing, and it is not security.
Same rule as `usePermissions`, which fails open on purpose: hiding an entry is politeness, the 403 is the
lock. Write the guard on the route first and the nav second, never only the second.
## What must be settled before any of this lands
**A member with no server has no Jellyfin.** Connections are stored strictly per user — every query in
`plugins/jellyfin/db/queries.ts` filters on `eq(jellyfinServers.userId, userId)`. Lock `/_config` to the
owner today and a Member gets a plugin that can only ever show them the connect form they are forbidden
to submit.
The resolution that fits what already exists: a non-owner resolves to the **owner's active server URL**
authenticated with **their own token**, which provisioning already mints and stores in
`jellyfin_accounts`. That is arguably why those tokens are minted at all — per-user identity upstream, so
resume points, watched marks and next-up stay each person's own rather than everyone sharing the owner's.
That is a change to credential resolution, not to authorization, and it is the actual prerequisite. The
guard is a few lines; this is the work.
## Status
| | |
| ----------- | ------------------------------------------------------------------------------------- |
| Agreed | plugin decides what a level means inside its own surface |
| Agreed | pass `isOwner` and the effective level; never the role name |
| Agreed | fail closed when the headers are absent |
| Built | `ownerOnly` on a plugin permission, enforced by the gate before self-service and kind |
| Built | `X-Officer-Owner` and `X-Officer-Level`, sent when a proxy names its permission key |
| Built | manifest validation — a sub-path without a leading slash is a manifest error |
| Blocked on | credential resolution for non-owners, above |
| Not started | any plugin actually declaring one |
+225
View File
@@ -0,0 +1,225 @@
# Push notifications — the app side
**Audience:** whoever builds this in `officerdev/monorepo-mobile`. **Platform status:** the server is built,
running and core; nothing on the device exists yet. **Target: iOS first, Android soon after.**
Read `docs/push-notifications.md` first — it is the design and the _why_. This document is the contract and
the build order, and it does not repeat the reasoning.
## What the server already does
- `officer-notify` runs as a core PM2 process and is reachable at `/api/notify/*`.
- `push_devices` exists in Postgres. Registration is idempotent.
- APNs and FCM channels are implemented and unit-tested; neither has ever sent a real push.
- **Missing: credentials.** Until the owner installs an APNs `.p8`, `/api/notify/_health` reports
`{"ok":true,"channels":[]}` and every send is a silent no-op that returns success. **A green response
does not mean a push was sent** — check `channels` first, or you will debug the app for a day over a
server with no key.
- **Alpha is up to date as of 2026-08-19 19:xx.** `/api/notify/*` answered `404` there for most of the day
because the server predated this work; it now answers `401` unauthenticated, `push_devices` has its 10
columns, and `officer-notify` is running. If you get a `404`, that server needs `git pull` +
`pm2 restart officer`, and `officer-notify` needs starting — it is a new core process, so an existing
install will not have it until `bun gen:ecosystem` runs.
## Which app
**Only the main Officer app (`apps/mobile`) receives pushes.** It is the hub: everything arrives there and
it deep-links onward to music, chat, photos and the rest.
`apps/chat` keeps `withoutPushEntitlement.js` — that plugin is **correct** and should not be removed. Chat
uses local notifications and is not a push receiver. Same for every other app in the monorepo.
## Phase 1 — first light
The goal is one plain banner on a real device. **No codes, no mapping, no extension.** If this does not
work, nothing later will, and everything later is harder to debug.
### 1. iOS project setup
- A bundle id for `apps/mobile` — **confirmed: `dev.officer.mobile`**, plus `dev.officer.mobile.dev`.
- `aps-environment` entitlement. Do **not** copy chat's plugin here; the hub needs the opposite.
- Push capability on the App ID in the developer portal, and a provisioning profile that includes it.
### 2. Get the token and register it
Ask for permission, get the **native APNs device token** (not an Expo push token — the server talks to
Apple directly and an Expo token will be rejected as malformed), then:
```http
POST /api/notify/_officer/devices
Authorization: Bearer <the app's normal Officer session token>
Content-Type: application/json
{
"token": "<64-hex APNs device token>",
"platform": "ios",
"environment": "production",
"bundleId": "dev.officer.mobile",
"appSlug": "mobile"
}
```
`token`, `platform`, `bundleId` and `appSlug` are **required**; a missing one is a `400` with
`{"error":"token, platform, bundleId and appSlug are required"}`. `environment` defaults to `production`.
Response: `{"ok":true,"id":<row id>}`.
**Never send a `userId`.** The server takes the user from the authenticated session and ignores the body —
that is deliberate access control, not an oversight. Sending one is harmless but will not do what it looks
like it does.
**`environment` is the classic silent failure.** An Xcode/debug build gets a **sandbox** token; TestFlight
and App Store builds get **production** ones. A sandbox token registered as `production` fails at Apple
with `BadDeviceToken` and **no user-visible symptom** — no error on the device, nothing in the app. Derive
it from the build configuration; do not hardcode it.
**Call this on every launch.** APNs tokens rotate. Registration is an upsert keyed on
`(token, bundleId)`, so re-registering is free and expected.
### 3. The other two routes
```http
GET /api/notify/_officer/devices → { devices: [ … ] }
DELETE /api/notify/_officer/devices/<token> → { ok: true }
```
`GET` returns `{ id, platform, environment, bundleId, appSlug, tokenSuffix, lastSeenAt }` per row —
**tokens are never echoed in full**, only the last 8 characters, so build any "your devices" UI against
`tokenSuffix` and `id`. Call `DELETE` on sign-out.
### 4. Ask for a test push
The owner triggers one server-side. You should get an ordinary banner. That closes the loop:
credential → APNs → device → banner.
## Phase 2 — the mapping
```http
GET /api/notify/mapping → { version: 47, entries: { "<code>": { title, body?, target, route, thread } } }
```
**Built and live** as of 2026-08-19. `Cache-Control: no-store` — the client decides by `version`, so a
cached copy would defeat the sync it drives. The catalogue is re-read from disk per request, so installing
a plugin changes the response with no restart.
Codes are namespaced `<publisher>/<appName>:<code>`, e.g. `officerdev/music:12.3`.
The app **fetches this and caches it into a shared App Group container** — shared because the notification
extension in Phase 3 must read it without the app running. Sync on launch, and whenever a push arrives
whose `mv` **differs from** the cached `version`.
**Compare with `!==`, not `>`.** `version` is a content hash, not a counter, so it does not increase
monotonically — removing a plugin returns it to the value it had before that plugin was installed. That is
deliberate: a counter would need persistent storage and would be wrong after a restore or a rollback.
Entries are templates: `title: "{n} new emails"` rendered against the push's `n`. `route` carries the deep
link with `{id}` interpolated.
## Phase 3 — the Notification Service Extension
A push arrives looking like this:
```jsonc
{
"aps": {
"alert": { "title": "Officer" }, // the fallback the OS shows if the extension does not run
"mutable-content": 1,
"sound": "default",
"thread-id": "officerdev/music",
"badge": 3,
},
"officer": { "c": "officerdev/music:12.3", "n": 3, "id": 412, "mv": 47 },
}
```
The extension resolves `c` against the **cached** mapping and rewrites the banner.
**It must not make a network request.** The lookup is a local dictionary read against the App Group cache.
This is the single most important constraint in the whole design: it is what makes notifications work when
the tailnet is down, on cellular, with the VPN off. An extension that fetches on arrival will appear to
work in every test you run at your desk and fail in the field.
**Unknown code** — the push outran the sync, or came from an uninstalled plugin:
> **Unknown notification**
> code `officerdev/music:12.3` — tap to sync
deep-linking to `officer://resync-notification-mapping`. Keep the code in the body; when this fires on the
owner's phone, _which_ code is the only useful thing to know.
That screen must be able to **give up**. An uninstalled plugin's codes are gone from the catalogue forever,
so "resync, still missing, resync" would loop. After one failed resync, say it came from something no
longer installed and stop.
**One build, ever.** The extension is a generic resolver — it must never `switch` on known codes, or every
new notification kind becomes an app store release, which is the entire thing this design exists to avoid.
Expo: `ios/` is generated by prebuild, so the NSE target comes from a config plugin. **One already exists
in this repo** — `packages/bw-vault/plugin/withVaultAutofillTarget.js` creates the AutoFill extension
target. Follow it rather than inventing one.
## Phase 4 — deep linking
`route` from the mapping is the destination. Tapping a notification opens the hub, which routes onward to
the target app or screen. The scheme is **`officer://`** (`officer-dev://` in the dev variant), from
`app.config.ts`.
## Android — later, but design for it now
No extension needed. Send **FCM data-only messages at high priority** and the app composes the notification
itself; same codes, same mapping, same cache. Register with `platform: "android"` and the FCM registration
token.
**Do not start Android before the owner settles one thing.** `apps/mobile/PUSH_NOTIFICATIONS.md`
(2026-07-24) rejects FCM because the Pixel runs GrapheneOS with no Play Services; the platform design chose
FCM a week later without addressing it. If the target device has no Play Services, FCM cannot reach it at
all and the fallback is a foreground service holding a tailnet socket. That decision changes the whole
Android build and is not yours to absorb.
## Things that will waste your time if nobody tells you
- **A successful `POST /_officer/notify` means "queued", not "delivered".** With no credentials configured
every channel is skipped and the call still succeeds.
- **`X-Officer-User` is injected by the platform proxy.** You never set it; if you see a `401` mentioning
it, the request bypassed the proxy.
- **Tokens are addresses, not secrets**, but the server still refuses to echo them back in full. Don't
build anything that needs the whole token back.
- **Until 2026-08-19 a non-owner could not register a device at all** — the permission's `personal` paths
did not match the real route. Fixed. If you test with a member account on an older server, that is why.
- **`appSlug` means the deep-link destination**, not "which app receives this". The name is a leftover from
the first design, where each app had its own token.
## Answered, from the app repo (2026-08-19)
**Bundle ids exist.** `dev.officer.mobile`, and `dev.officer.mobile.dev` for the side-by-side dev variant.
Two bundle ids is fine and needs nothing special: `push_devices` is unique on `(token, bundleId)`, so the
two variants register as separate rows on the same phone. **Register the dev variant as
`environment: "sandbox"` and production as `"production"`** — that mapping is now concrete, so derive it
from the variant rather than the build config guesswork this document suggested earlier.
**The URL scheme is `officer://`, and `officer-dev://` in the dev variant.** Both come from
`app.config.ts`, and the share extension embeds the same string natively, so they cannot desync. So the
unknown-code fallback deep link is `officer://resync-notification-mapping` as written.
**Expo prebuild (CNG), not bare** — `ios/` is generated and gitignored; everything native comes from config
plugins. This is better news than the document assumed: **a config plugin that creates an iOS app-extension
target already exists in this repo**, `packages/bw-vault/plugin/withVaultAutofillTarget.js`, which builds
the AutoFill extension that prebuild cannot. **The NSE follows that pattern rather than being invented**,
which removes what this document called the longest pole in Phase 3.
**`expo-notifications` is NOT in `apps/mobile`** — only in `apps/chat`, for local turn alerts. Correcting
this document's assumption. Chat's `withoutPushEntitlement.js` still stays exactly as described above; the
hub needs the opposite.
## The one real blocker: the push entitlement
Today's signed build (`2026.08.19.02`) has **no `aps-environment` entitlement at all**. Nothing else in
Phase 1 can be tested until it does.
It has a known shape here. On 2026-08-05 adding a Network Extension failed for the same reason:
`-allowProvisioningUpdates` **cannot add a capability while Xcode has no account signed in**. The fix is a
one-time manual step by the owner — Xcode → Settings → Accounts, team `RGHC4NJC77` — after which it is
automatic again.
**Owner action, and it is the critical path.** Along with the APNs `.p8`, this is the whole of what stands
between here and a banner on a phone.
+291 -82
View File
@@ -1,133 +1,342 @@
# Push notifications — design # Push notifications — design
**Status:** agreed design, 2026-07-31. Implementation starting. Server side; the app side is a separate **Status (2026-08-19):** server side **built and core**; app side **not started**. This is the second
document handed to the apps repo (`officer-suite/COMMS/PUSH_NOTIFICATIONS_APP.md`). design. It supersedes the 2026-07-31 one, which is summarised under "What changed and why" so the
reversals are legible rather than silently overwritten.
## What was decided, and what was rejected The app-side contract and build order is **`docs/push-notifications-app.md`** — hand that to whoever builds
the client. This file is the design and the reasoning.
**Apple and Google are unavoidable and we accept them.** There is no direct server→phone push on either ## The shape, in one paragraph
platform: iOS suspends apps, so only APNs can wake one, and Android only accepts pushes from FCM. An
earlier plan had these the other way round — believing iOS could be pushed to directly and Android needed
a bridge. It is the reverse.
**Expo is rejected.** Its push service is a relay in front of APNs and FCM, and it does not remove either One app on the device — the main Officer app — receives every push and is the only one that can. It is a
credential — a standalone Android build still needs an FCM service account, which you would upload *to **hub**: the payload it gets is a **code** plus a few numbers, and the app turns that into the notification
Expo*. All it adds is fan-out convenience, in exchange for our payloads and our APNs key living on a you actually see, deep-linked to whichever app and event it concerns. The code's meaning lives in a
fourth party's servers. We talk to Apple and Google ourselves. **mapping owned by the server** and synced to the device ahead of time, so adding a new kind of
notification is a server change and never an app build. Plugins declare their own codes the same way they
declare affordances.
**The Android foreground-service alternative is rejected for now.** A persistent socket plus a local ## What changed and why
notification genuinely avoids Google, and the tailnet makes the connection easy. It costs a permanent
"Officer is running" entry in the notification shade and is unreliable against OEM battery managers
(Samsung, Xiaomi). Recorded here because it remains the fallback if FCM ever becomes unacceptable.
## Payload policy — the notification is a doorbell, not a message The first design was a **doorbell**: a category and an id, never content, with the visible text composed
server-side from the category alone. It called that "the load-bearing decision, and it is not optional".
**This is the load-bearing decision, and it is not optional.** Three things moved.
Both services see metadata regardless: which device, how often, at what times. What they must not see is **1. Privacy relaxed, but not abandoned.** The original weighting — nothing readable may reach Apple or
content. So a push carries a category and an id, never the substance: Google — was reconsidered on 2026-08-19 as stricter than the owner actually wanted. What replaced it is
not "send plaintext": it is **codes**, which keeps content off Apple's and Google's servers while removing
the reason the doorbell was painful (a rigid category enum). The privacy property survives almost intact;
what was given up is the claim that it was inviolable.
``` **2. One receiver, not many.** The first design had each app register its own token and `appSlug` select
✅ { type: 'mail', count: 3 } ❌ { title: 'Re: invoice', from: 'x@y.com' } which app a notification went to. Now a single hub app receives everything and `appSlug` names the
✅ { type: 'job', id: 412, ok: false } ❌ { error: 'ENOENT /home/pastilhas/…' } **deep-link destination** instead. Same field, opposite direction — see "Migration notes".
**3. Extensibility became the priority.** The binding constraint is now "a new notification kind must not
require an app store build". That single requirement is what forces the server-owned mapping, and it is
worth more than the category purity the first design optimised for.
**What did not change:** content still does not travel. Apple and Google see a code and some integers.
## Why a hub app
- **One entitlement, one topic, one sender.** A single bundle id means one `aps-environment`, one APNs
topic and one FCM sender, instead of credentials multiplying per app.
- **It resolves an existing contradiction.** `apps/chat` ships `withoutPushEntitlement.js`, a config plugin
that deliberately STRIPS `aps-environment` because chat uses local notifications only. Under the first
design that was a blocker. Under this one it is correct and stays — chat is not a push receiver, the hub
is.
- **It concentrates the Android problem** rather than multiplying it (see "Android and GrapheneOS").
## The payload
An ordinary **alert** push — not a silent one — carrying a code and numbers:
```jsonc
{
"aps": {
"alert": { "title": "Officer" }, // fallback only; see "What the OS shows"
"mutable-content": 1,
"sound": "default",
"thread-id": "officerdev/music",
"badge": 3,
},
"officer": {
"c": "officerdev/music:12.3", // the code — namespaced, see below
"n": 3, // variable numbers, never content
"id": 412, // what to fetch/open, never a human-readable string
"mv": 47, // mapping version the server rendered against
},
}
``` ```
The app already has a tailnet connection. On tap it fetches the real content itself. The visible text is **Codes are namespaced, for the same reason affordances are.** `<publisher>/<appName>:<code>`. Plugins from
generic ("3 new emails"), assembled on the device from the category, not sent through Apple or Google. different publishers will otherwise collide on a bare integer, and a code in a log should say whose it is
without a lookup. `core` occupies the appName slot for the platform's own.
Cost of both services: **free**. No per-message charge on either. **Variable data rides beside the code, never inside it.** The mapping holds a template — `"{n} new emails"` —
and the push supplies `n`. Numbers and ids are not content, so the privacy property survives templating.
**What does NOT go in:** subject lines, senders, filenames, error strings, agent output, paths. If a value
would embarrass you in a Google log, it belongs behind `id` and gets fetched over the tailnet on tap.
## What the OS shows, and why `mutable-content` is required
A normal alert push is rendered **by the operating system, not by the app**. Send `alert.title = "12.3"`
and the user sees `12.3` on the lock screen — the app never gets a chance to touch it.
So the hub needs a **Notification Service Extension**, enabled per-push by `mutable-content: 1`. The
extension runs before display, resolves the code, and rewrites the banner.
**Crucially, the extension does a LOCAL lookup, not a network fetch.** The mapping is synced in advance by
the app into a shared App Group container; at notification time it is a dictionary read. This is what makes
the design work where a fetch-on-arrival design would not:
- no dependence on the tailnet being connected when the push lands
- no 30-second budget to overrun
- no partial failure mode on cellular with the VPN off
**This costs exactly one app build, ever — never one per notification kind.** An extension that hardcoded a
`switch` on type would need rebuilding for every new kind; this one is a generic resolver and does not.
**The alert text you send is the degraded case.** If the extension does not run — low memory, a crash, an
older OS — the user sees exactly what was in `alert`. So it is a graceful generic ("Officer"), deliberately
**not** plausible-looking decoy text: a convincing fake sentence would mislead the one person who most needs
to trust it.
**Silent pushes were considered and rejected.** `content-available: 1` with no alert would let the app
compose everything and leak least, but Apple throttles silent pushes and explicitly does not guarantee
delivery. For notifications that must arrive, that is the wrong tool.
**Android needs no extension.** An FCM **data-only message at high priority** wakes the app, which composes
the notification itself. Same codes, same mapping.
## The mapping
The server owns it; the device caches it.
```
GET /api/notify/mapping → { version: 47, entries: { "officerdev/music:12.3": {...} } }
```
An entry says how to render and where to go:
| field | note |
| -------- | -------------------------------------------------------------- |
| `title` | template, `{n}`-style placeholders |
| `body` | template, optional |
| `target` | which app the deep link opens |
| `route` | the deep link, with `{id}` interpolated |
| `thread` | grouping key, so like-notifications collapse rather than stack |
**Plugins declare their own**, on the affordances pattern (`src/servers/notifications/publish.ts`, which
deliberately mirrors `affordances/publish.ts`): a `notifications/` directory of YAML in the plugin
repository, copied to `capabilities/notifications/<publisher>/<appName>/` on install and removed on
uninstall.
The namespace is **derived from where the file lands, never from what it declares** — the same rule as
`mountPrefix`. A plugin therefore cannot claim `officer/core:` or another publisher's codes by writing
them into its own catalogue; it would have to install itself somewhere it cannot. This is pinned by test. That mechanism already exists and is understood; a second one for
notifications would drift from it. The platform aggregates every installed plugin's catalogue plus its own
into the response above, and `version` increments whenever that aggregate changes.
### Staying in sync, and the fallback
Every push carries `mv`, the mapping version it was rendered against. The app compares it to its cached
version and **resyncs in the background when they differ** — so the mapping normally catches up before an
unknown code can arrive.
`version` is a **content hash, not a counter**, so the test is `mv !== version` rather than `mv > version`.
A counter would need somewhere to live and would then be 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.
When a push still outruns the sync, the fallback renders:
> **Unknown notification**
> code `officerdev/music:12.3` — tap to sync
deep-linking to `officer://resync-notification-mapping`. The code goes **in the body on purpose**: when this
fires on the owner's own phone, the first useful question is which code the server sent that the app did not
have, and without it that is debugged blind.
**The resync screen must be able to give up.** If a plugin is uninstalled its codes leave the catalogue, so
a queued notification for one will resync, still not resolve, and otherwise loop forever. That path ends
with "this came from something no longer installed", not another retry.
## What this still leaks
Apple and Google see **which code, how often, at what time, to which device**. Over months that is a
fingerprint of the owner's category vocabulary even though every code is opaque. That is much less than
plaintext and is judged acceptable — but it is not nothing, and this document does not claim otherwise.
## Where it lives ## Where it lives
A new **`officer-notify` sidecar**, PM2 peer, its own loopback HTTP listener, announced as `officer-notify`, a **core** PM2 process since 2026-08-19 (built 2026-07-31; spent 2026-08-13 to 08-19
`notify:server` and proxied at `/api/notify` by `createSidecarProxy`. switched off after being slated for plugin extraction and never extracted). Core rather than a plugin
because the producers are the platform's own — the job queue, an agent turn ending, mail arriving — and a
Not the platform, because the producers are spread out — the queue, the email sidecar, the agent sidecar — notifier the owner can uninstall is one those producers cannot rely on.
and a platform-owned notifier would force every sidecar to call *back* into the platform. That is the
inversion just removed from email. As a sidecar, anything POSTs to it over loopback.
It also absorbs `src/servers/notify/discord.ts`, so there is one outbound-notification surface with
channels behind it rather than a Discord path and a push path that do not know about each other.
``` ```
producer (queue / email / agent / platform) producer (queue / email / agent / platform / plugin)
│ POST /_officer/notify { type, ... } │ POST /_officer/notify { code, userId, n?, id? }
▼ ▼
officer-notify ─┬─ apns → api.push.apple.com (HTTP/2, ES256 JWT) officer-notify ─┬─ apns → api.push.apple.com (HTTP/2, ES256 JWT)
├─ fcm → fcm.googleapis.com/v1 (OAuth2 bearer) ├─ fcm → fcm.googleapis.com/v1 (OAuth2 bearer)
└─ discord→ webhook (existing) └─ discord→ webhook
``` ```
It boots with **no credentials at all** — every channel reports `isConfigured() === false` and `dispatch`
resolves empty — so an install that never sets an APNs key runs it as a no-op rather than crash-looping.
That is what makes it safe as core.
## Credentials ## Credentials
Env only, on the sidecar, never in the platform process and never in the database. Env only, on the sidecar, never in the platform process and never in the database. All read
`OFFICERDEV_<NAME> ?? <NAME>`.
| var | what | | var | what |
|---|---| | --------------------- | ------------------------------------------------ |
| `APNS_KEY_P8` | contents of the `.p8` auth key (PKCS#8 EC P-256) | | `APNS_KEY_P8` | contents of the `.p8` auth key (PKCS#8 EC P-256) |
| `APNS_KEY_ID` | the key's 10-char Key ID | | `APNS_KEY_ID` | the key's 10-char Key ID |
| `APNS_TEAM_ID` | Apple Developer Team ID | | `APNS_TEAM_ID` | Apple Developer Team ID |
| `APNS_ENV` | `production` or `sandbox` — different hosts AND different tokens | | `FCM_SERVICE_ACCOUNT` | the service-account JSON |
| `FCM_SERVICE_ACCOUNT` | the service-account JSON | | `DISCORD_WEBHOOK_URL` | existing |
| `DISCORD_WEBHOOK_URL` | existing, moves here |
The `.p8` does not expire and can push to your apps forever. Treat it as spending-grade. The `.p8` does not expire and can push to your apps forever. Treat it as spending-grade.
**The secret store is where these live now** — env is only the fallback.
```bash
cat AuthKey_ABCD123456.p8 | bun run secret:set apns-p8
echo -n ABCD123456 | bun run secret:set apns-key-id
echo -n RGHC4NJC77 | bun run secret:set apns-team-id
pm2 restart officer-notify # _health should then report channels: ["apns"]
```
Purposes: `apns-p8`, `apns-key-id`, `apns-team-id`, `fcm-service-account`, `discord-webhook`. Read with
`getSecret` and NOT `getKey` — `getKey` mints a random value for an absent purpose, which for a credential
supplied from outside would mean silently authenticating to Apple with a fake.
Env is still read when the store has nothing, so an install that predates this keeps working. But it is a
fallback: bun auto-loads `.env` into every PM2 process, so a key there is readable from `/proc/<pid>/environ`
of twenty of them, and `officer-music` has no business being able to read a credential that can push to your
apps forever.
`APNS_ENV` appeared in the first design's table and is deliberately **not** implemented: the host is chosen
per device from the `environment` column, not per process, so one server pushes to a TestFlight build and a
release build at the same time.
## Both protocols, verified in Bun before designing around them ## Both protocols, verified in Bun before designing around them
- **APNs**: HTTP/2 only. `node:http2` works in Bun 1.3.10 (checked against a live server). Auth is an - **APNs**: HTTP/2 only. `node:http2` works in Bun. Auth is an ES256 JWT signed with the `.p8`, valid ≤1h,
ES256 JWT signed with the `.p8`, valid ≤1h, refreshed no more often than every 20 min or Apple rejects refreshed no more often than every 20 min or Apple rejects it. **The signature must be raw `r||s`
it. **The signature must be raw `r||s` (64 bytes)** — `createSign(...).sign({ key, dsaEncoding: (64 bytes)** — `createSign(...).sign({ key, dsaEncoding: 'ieee-p1363' })`. Node's default DER encoding is
'ieee-p1363' })`. Node's default DER encoding is silently rejected. silently rejected.
- **FCM v1**: ordinary HTTPS. Sign an RS256 JWT with the service-account key, exchange it at - **FCM v1**: ordinary HTTPS. Sign an RS256 JWT with the service-account key, exchange it at
`oauth2.googleapis.com/token` for a 1-hour access token, cache that, then POST to `oauth2.googleapis.com/token` for a 1-hour access token, cache it, then POST to
`fcm.googleapis.com/v1/projects/<id>/messages:send`. `fcm.googleapis.com/v1/projects/<id>/messages:send`.
No push library is needed for either — `node:crypto` and `node:http2` cover it. All three endpoints are No push library is needed for either.
reachable from this host.
## Device registry ## Device registry
New table `push_devices`: `push_devices` — live in the schema as of 2026-08-19.
| column | note | | column | note |
|---|---| | --------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `id` | serial | | `id` | serial |
| `user_id` | fk users | | `user_id` | fk users |
| `token` | the native token — APNs device token or FCM registration token | | `token` | APNs device token or FCM registration token |
| `platform` | `ios` \| `android` | | `platform` | `ios` \| `android` |
| `environment` | `production` \| `sandbox` — an iOS dev-build token fails against prod with a silent `BadDeviceToken` | | `environment` | `production` \| `sandbox` — a dev-build token fails against prod with a silent `BadDeviceToken` |
| `bundle_id` | `apns-topic`; also distinguishes the three apps | | `bundle_id` | `apns-topic` |
| `app_slug` | `mobile` \| `music` \| `read-aloud` | | `app_slug` | see migration note |
| `last_seen_at`, `failure_count`, `created_at` | pruning | | `last_seen_at`, `failure_count`, `created_at` | pruning |
Unique on `(token, bundle_id)`. Registration is idempotent: the app re-registers on every launch, since Unique on `(token, bundle_id)`. Registration is idempotent: the app re-registers on every launch, since
tokens rotate. tokens rotate.
## Failure handling — the part that is usually skipped ### Migration notes from the first design
**APNs** answers inline: `410 Unregistered` or `400 BadDeviceToken` means delete the row immediately. - **`appSlug` on a NOTIFICATION inverts.** It meant "deliver only to this app". It now means "the deep-link
destination". Nothing consumes it yet, so this is a rename-in-place, not a data migration.
- **`bundle_id` / `app_slug` on a DEVICE go near-constant.** The columns exist to let several apps register
separately; with a hub there is one bundle id per device. Keep them — a second receiver later (a watch, a
desktop build) costs nothing this way — but "which apps has this user got" stops being a delivery question.
- ~~**`NotifyType` is replaced by codes.**~~ Done. The producer contract is now
`POST /_officer/notify { code, userId, n?, id? }`, validated on the SHAPE of the code
(`<publisher>/<appName>:<key>`) and deliberately **not** against the catalogue — a producer firing a
code the catalogue has not caught up with is sending a real event, and the device already has a defined
fallback. `ok` is gone: a failed job is its own code (`officer/core:job-failed`), which is what made the
enum rigid in the first place.
- ~~**`text.ts` moves to the device.**~~ Done — replaced by `render.ts`, which renders server-side only for
Discord (no app to resolve a code) and for the `alert` fallback string.
**FCM** answers inline too: `UNREGISTERED` / `INVALID_ARGUMENT` means delete. ## Failure handling
Everything else increments `failure_count`; three strikes and the row goes. Without this the registry **Only "the app is gone" prunes a row.** The first version pruned on every 4xx Apple or Google offered,
fills with dead tokens and delivery quietly degrades. and that was wrong in a way that hides itself.
(If Expo were used, this would instead require a deferred receipt poll ~15 min after send. Going direct | reason | what it means | what happens |
removes that entire mechanism — a genuine simplification, worth noting against the fan-out we gave up.) | ------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
| `Unregistered` (410), `UNREGISTERED`, `NOT_FOUND` | the app was uninstalled | **delete the row** |
| `BadDeviceToken`, `DeviceTokenNotForTopic`, `TopicDisallowed` | **our** configuration is wrong | keep it, log the diagnosis |
| `INVALID_ARGUMENT`, `SENDER_ID_MISMATCH` | **we** sent something malformed | keep it, log the diagnosis |
| anything else | transient | `failure_count += 1`, three strikes and the row goes |
## Open, deliberately deferred `BadDeviceToken` is almost always an environment mismatch — a token minted under
`aps-environment: development` sent to the production host, or the reverse. The device is fine, the app is
installed, the registration is good; the server dialled the wrong Apple. **Deleting the row there destroys
the evidence**: the phone re-registers on next launch, the row comes back looking healthy, and the next
send fails identically. Found in the field on the first real push, 2026-08-19.
- **Which events notify.** Out of scope by instruction: build the pipe first. Candidates when we get `INVALID_ARGUMENT` is worse still, because it is how FCM reports a malformed MESSAGE — including a
there: job finished, new mail, long agent turn done, download complete. non-string in `data`, the mistake this codebase is most likely to make and has a test for. Pruning on it
- **Quiet hours and batching.** New mail on an 18k-mail account is unusable at one push per message. means one bug in the payload builder wipes every Android device in the registry on a single send.
- **`apps/mobile` EAS/bundle id** — only `apps/music` is confirmed to have one (`dev.officer.music`).
**Misconfigured is not counted as a strike either.** Not deleting is not enough on its own: a persistent
mismatch would otherwise reach three failures and delete the row anyway. Same bug, slower fuse.
## Android and GrapheneOS — UNRESOLVED, and it decides Android
`apps/mobile/PUSH_NOTIFICATIONS.md` (2026-07-24) rejects FCM outright: _"requires Google Play Services; the
Pixel runs GrapheneOS without it."_ The 2026-07-31 platform design chose FCM for Android a week later and
does not mention GrapheneOS. **Nobody reconciled the two, and this design does not either.**
It matters because FCM cannot reach a device with no Play Services, so on such a phone the entire Android
path is inert regardless of anything above.
The recorded fallback is a **foreground service holding a tailnet socket**, with notifications raised
locally. It was rejected in the first design for a permanent "Officer is running" entry in the shade and
unreliability against OEM battery managers. **The hub changes that calculus**: one socket and one shade
entry for the whole platform, not one per app. The battery-manager objection is also weaker on GrapheneOS
than on Samsung or Xiaomi.
This needs an owner decision before Android work starts. iOS is unaffected and can proceed independently.
## Build order ## Build order
1. `push_devices` table + `bun db:push`. 1. ~~`push_devices` table.~~ done
2. `officer-notify` sidecar shell: listener, `notify:server`, PM2 entry, `/api/notify` proxy. Discord 2. ~~`officer-notify` sidecar, PM2 entry, `/api/notify` proxy, Discord channel.~~ done
channel moved in — one real channel end-to-end before any push credential exists. 3. ~~APNs channel + device registration endpoint.~~ done, 27 tests
3. APNs channel + `POST /_officer/devices` registration. 4. ~~FCM channel.~~ done, 27 tests
4. FCM channel. 5. **APNs credentials.** Owner action; nothing can be tested end-to-end without them.
5. First producer wired. 6. **Hub app registers its token** against `POST /api/notify/_officer/devices`. Small.
7. **First light**: a `test` push rendered by the OS with plain text, no codes, no extension. Proves
credential → APNs → device → banner as one thing, before any of the mapping exists.
8. **Codes and the mapping**: `NotifyType` → codes, `GET /api/notify/mapping`, plugin catalogues on the
affordances pattern.
9. **The Notification Service Extension**, local lookup, App Group container.
10. **First real producer** — job finished is the obvious one.
Steps 7 and 8 are deliberately separable: the transport can be proven working before the rendering exists,
and if it is not, no amount of mapping work will help.
## Still open
- **Which events notify**, beyond the first producer. Quiet hours and batching — new mail on an 18k-mail
account is unusable at one push per message.
- **Whether members can register devices.** The `notify` permission is `kind: 'app'`, so it is grantable per
role and denied by default; a member with no grant cannot subscribe their own phone.
- **Android**, per above.
+210
View File
@@ -0,0 +1,210 @@
# Roles and teams
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
design, written down so the work starts from a spec rather than from a conversation.
## What this is
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
a homelab and it cannot express a company, where the same person is an administrator of one area and an
ordinary member of another.
Two things are wanted, and they are separable:
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
provisioning.
- **Teams**, so a user belongs to several and holds a different role in each.
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
uses both. Nothing below may make teams mandatory to reach a feature.
---
## What already landed — phase 0, 2026-08-23
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
Verified against a scratch database: push reported the violation, created the table, skipped the
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
---
## Decisions
### D1. The owner is an identity. It is never a role.
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
whatever UI manages roles.
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
else can wait behind it.
### D2. Several Super Admins are allowed, and the role provisions sudo
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
a company has an owner and several administrators, and a homelab owner may add one or never do.
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
they left behind.
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
the UI to say what it is rather than presenting it as one more row in a dropdown.
### D3. `Admin` is app-wide authority with no operating-system privilege
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
"administering the platform is not developing on it". This decision only writes that down.
### D4. A role may be deleted, but not while anyone holds it
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
nobody asked for, and "absence denies" is the posture everywhere else in this system.
`isSystem` roles refuse both delete and rename, because the code names them by name:
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
`Member`. Renaming one is a code change that has not happened yet.
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
rename cascades to every user and every grant, atomically, with no code change.
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
### D6. A team is a scope. Permissions UNION across a user's teams.
Admin in marketing and member in developers means marketing's admin level does not reach developers'
files — but a user's effective permission for a given capability is the **highest level any of their teams
grants**, plus their global role.
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
should be refused rather than half-built.
### D7. The global role remains, and answers a different question
Two questions, two mechanisms:
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
- **"What may this account do in this area?"** — team membership with a role in that team.
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
has no natural team to ask it about. A homelab uses only the first and never makes a team.
### D8. Per-role provisioning is a script the role carries
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
in code that should become a row.
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
rootless specifically. `usermod -aG docker` is root by a longer route —
`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet seed. Any
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
---
## Phases
### Phase 0 — roles are a table ✅ DONE 2026-08-23
Additive only. Nothing reads it.
### Phase 1 — the owner stops being a role
The load-bearing phase, and small.
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
"owner or Super Admin" is a separate, reviewable change per route.
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
Reviewable on its own, and nothing downstream can start until ownership is an identity.
### Phase 2 — the foreign key, and role CRUD
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
replace what the compiler was doing.
### Phase 3 — per-role provisioning
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
D8's warning in front of whoever writes the first script.
### Phase 4 — teams
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
one table with a nullable `team_id`.
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
4. Teams UI, and the account screen showing which teams a user is in and as what.
### Phase 5 — the thing that actually unblocks all of it
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
for accounts that cannot yet exist.
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
find nothing to test it with. **It may be worth doing first.**
---
## What must not be built
- **Deny rules.** See D6. Teams grant; they never restrict.
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
the answer is a `confined` grant with a Linux user, which is per-account by nature.
## Verification
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
binary directly.
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
## Open questions
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
or a self-service one.
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
holds globally; the same question exists per team and is not answered here.
+93
View File
@@ -0,0 +1,93 @@
# SendOff — files land in the user's own tree, and a push says where
**From:** mobile · **Date:** 2026-08-23 · **v2, replacing this morning's spec entirely.** The v1
design (staging area, transfer/target/file tables, ack lifecycle, TTL cleanup) is withdrawn — Andre's
observation kills it: every user already has a filesystem on the server, a file browser on the web,
and a Files app on the phone. SendOff is not a new storage system; it is a folder convention, one
endpoint, and a push.
## The convention
```
~/Downloads/sendoff/from-<username>/ ← a file another USER sent you
~/Downloads/sendoff/<machine>/ ← a file you sent yourself, aimed at a named device
```
That is the whole data model. Nothing to ack, nothing to expire, nothing to clean up — the files are
ordinary files in the recipient's tree, visible in every file UI that already exists, managed like
anything else they own.
## The endpoint
```
POST /api/sendoff/send
multipart: any number of `file` parts + a `meta` part:
{ "toUser": "<username>" } — cross-user: writes into THEIR tree, from-<sender>/
{ "machine": "<device name>" } — self: writes into own tree, <machine>/
→ { path: "Downloads/sendoff/…", files: [{ name, size }] }
```
Server-side because of the cross-user case: `/api/file-browser/upload` correctly writes only the
caller's own tree, and that must not change. This endpoint is the one sanctioned door into another
user's filesystem, and it writes ONLY under `Downloads/sendoff/from-<sender>/` — the sender's
identity comes from their token, never from the payload, so the folder name cannot lie about who
sent it. Filenames sanitised, collisions suffixed `(1)` — never overwritten; a second send of the
same name is a second file.
Whether user A may send to user B at all is a platform policy decision (everyone on the server? a
grant?) — flagging it rather than deciding it here.
## The push
On write, push to the recipient's devices through the existing pipeline:
```
{ "officer": { "c": "officer/core:sendoff.landed", "path": "Downloads/sendoff/from-andre" } }
alert: "photo.jpg from andre" / "3 files from omega"
```
The push catalogue's route for the category:
```
officer://files?path={path}
```
Mobile opens routes from the catalogue verbatim (`openNotificationRoute`), and Officer now parses
`officer://files?path=…` into the Files app at that folder. The web can deep-link its file browser
the same way.
For a `machine`-targeted send, push only to that machine's device if it can be identified; to all
the user's devices otherwise — the path in the folder name already says who it was for.
## Devices
The `machine` name needs a picker on the sending side, so a minimal registry survives from v1:
```
devices: id (client uuid), user_id, name, platform, last_seen_at
POST /api/devices/register { id, name, platform } (upsert; called on every app start)
GET /api/devices → { devices } (the "send to my…" picker)
PATCH /api/devices/:id { name } · DELETE /api/devices/:id
```
No link to transfers — nothing references a device row except the picker and, best-effort, push
targeting.
## Web UI
One send form: files + target (one of my machines / another user), POST, done. Received files need
no UI at all — they are in the file browser already.
## What mobile already does (shipped against this)
- Registers the device on start (silent failure until the endpoint exists).
- Parses `officer://files?path=…` and opens the Files app at that path — this is the piece that
makes the push tap land somewhere.
- SendOff screen: the device's name (the folder senders will aim at), and an explanation of where
arrivals land. Sending FROM the phone comes later, likely through the existing share sheet.
## Out of scope
Receiving in the browser (already exists — it is the file browser); cross-user permission model
(platform decision); phone-side arbitrary-file sending (needs a document picker; the share sheet is
the natural door and is a later step).
+153
View File
@@ -0,0 +1,153 @@
# Servers — the running list
Every machine the owner runs, how to reach it, and what of Officer is on it. Kept here so an
orchestrator ("pull and update every platform", "restart officer everywhere", "which host is behind")
has one place to read rather than a fact re-derived per session.
**Verified 2026-08-21** from `officer-dev` by SSH and `pm2 jlist`, except where marked assumed.
## How access works
All of it is over the tailnet, key-based, no `~/.ssh/config`, no passwords, no jump hosts, no ports.
**The login user is NOT always `pastilhas`.** It is per host and it is in the table below. `deadpool` runs
as `br` and always will — the owner's key is authorised for that account. An orchestrator that hard-codes
a username fails on that host and will fail on the next one like it.
```bash
ssh alpha # pastilhas, implied
ssh br@deadpool-rezio-bridge # not implied
```
## Officer platforms
Seven, all running.
| Host | Tailnet IP | Login | Checkout | Domain |
| ---------------------------- | ----------- | ----------- | ----------------------------------- | --------------------------------- |
| `officer-dev` | 100.64.0.13 | `pastilhas` | `~/officer-dev/platform` | `platform.officer.dev` |
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
## Deliberately skipped
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
and it must not be reported as one.
| Host | Since | Why |
| ---------------------- | ---------- | ---------------------------------------------------------------- |
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
Its row, kept so restoring it is one move:
```
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
```
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
is what this section exists to prevent: a host that is known to be down should cost a line in a report,
not a retry budget.
### Names that disagree with themselves
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
| In `tailscale status` | Also | The machine's own hostname |
| ----------------------- | ---------------------------- | -------------------------- |
| `edge-bridge` | `edge-pertento-bridge` | `edge-pertento` |
| `gitea-bridge` | `production-pertento-bridge` | `PPROD` |
| `deadpool-rezio-bridge` | — | `deadpool` |
The middle column is what to use. `edge-bridge` and `gitea-bridge` are the old names and are being
retired; anything reading `tailscale status` will still see them.
### Things that will catch out an orchestrator
All verified, none cosmetic.
- **The checkout path differs per host** — four distinct shapes across seven machines. Take it from PM2:
the `officer` process's `pm_cwd`. `alpha` carries a SECOND, stale checkout at
`~/dockers/officer.dev/platform`, and pulling that one succeeds while changing nothing.
- **`pm2` is not on a non-interactive PATH.** It lives under nvm, and nvm's setup is in `.zshrc`, which a
_login_ shell does not read. Use `ssh <host> '$SHELL -ic "pm2 jlist"'`. `-lc` is **not** enough — it
reports "command not found" on machines where pm2 is plainly running, which cost two wrong conclusions
on 2026-08-21.
- **On `macbookpro`, APPEND to the PATH for `bun`. Never replace it.** This said "do not help the PATH"
until 2026-08-21, and that was half right in a way that cost a deploy step: `pm2` is only on the
interactive shell's PATH, so replacing it breaks `pm2` — but `bun` is at `/opt/homebrew/bin/bun` and
`.zshrc` does not put that directory on the interactive PATH either, so `bun run plugins:update` fails
`command not found` before it runs a line. `export PATH=$PATH:/opt/homebrew/bin` for that one command
shadows nothing: `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. Run `pm2` as a separate
command.
- **`$SHELL -ic` on `macbookpro` aliases `ls` to `eza`, which is not installed.** A probe using `ls`
reports a missing file that is plainly there. Use `command ls`, or a non-interactive shell for file tests.
- **The git remotes differ.** `fofos` pulls over HTTPS; the others over SSH. Never assume a URL — pull in
place.
- **A "dirty tree" means TRACKED changes.** `git status --porcelain` counts untracked files, and a stray
script a fast-forward could never touch is not a reason to skip a host. Use `--untracked-files=no`.
- **`macbookpro` is a laptop.** It may be asleep. Unreachable is a skip with a note, not a failed deploy.
- **`bun run plugins:update` restarts sidecars itself**, printing `sidecar: started` per plugin — on
`alpha` that is twelve processes bouncing during the plugins step, before any restart the plan asked for.
Not a fault, but a deploy report that lists only `officer` under "restarted" is understating what moved.
- **`fofos` failed its capabilities sync for three deploys, and it was ownership.** Fixed 2026-08-21.
Two errors that read as unrelated problems — `dubious ownership` from git, and
`EACCES … mkdir '…/capabilities/affordances/officerdev'` from the gitea plugin — were one cause in two
dialects: fifty paths under that checkout were owned by `root`, including `.git` and `affordances`, all
stamped `19 Aug 21:43`. Something was run with `sudo` and left them behind. Mode `755` is why the split
looked odd: everything could be READ, so git saw the repo and refused it on ownership while the plugin
saw `affordances` and could not write into it.
One `stat` settled it, and that is the general lesson — `stat` prints the owner rather than inferring
anything, so it either proves ownership is the cause or eliminates it, in one command. Fixed with
`sudo chown -R pastilhas:pastilhas ~/officerdev/capabilities`; verified after by a `mkdir` into the
directory that used to refuse one.
**A `safe.directory` entry was deliberately NOT used**, and that call is worth keeping. It would have
silenced the git message and left the `EACCES` untouched — one symptom of a two-symptom cause hidden,
with the obvious explanation spent.
## Not platforms — ignore for now
A checkout may be present; no `officer` process runs on any of these. Whether they are retired, staging
or half-installed is **not established** — ask before treating them as targets.
| Host | Tailnet IP |
| ----------- | ---------- |
| `headscale` | 100.64.0.2 |
| `omega` | 100.64.0.3 |
| `gateway` | 100.64.0.4 |
## Other machines on the tailnet
- **Not SSH-reachable** (port 22 refused): `edge-dns` (100.64.0.8), `officer-bridge` (100.64.0.5).
Assumed service-only containers — not confirmed. `edge-bridge` was listed here until 2026-08-21 and was
wrong: its SSH was simply down at the time of checking.
- **Client devices, not servers:** `iphone16`, `andres-ipad`, `pixel9a`, plus other people's devices in
the tailnet (`diogos-ipad`, `iphone-diogo`, `iphone-bruno`, `fedras-iphone`,
`guilhermes-macbook-pro`, `macbook-pro-de-joo`).
## Known wrong, waiting on someone else
- **`officer.rezio.dev` resolves to 100.64.0.4** — that is `gateway`, not `deadpool` (100.64.0.21). Not
merely unavailable: pointed at a different machine, so it will serve the wrong box the moment gateway
answers on 443. Ports 80 and 443 are closed on `deadpool`, so nothing fronts it yet. Waiting on the
domain owner.
## Keeping this list true
It rots when a server is added, a checkout moves, a platform is retired, a remote changes or a login
differs. Re-verify with the four commands it was built from:
```bash
tailscale status
ssh <login>@<host> '$SHELL -ic "pm2 jlist"' # the officer entry's pm_cwd
ssh <login>@<host> 'git -C <cwd> remote get-url origin'
getent hosts <domain>
```
+149
View File
@@ -0,0 +1,149 @@
# Nightly visual regression, on a second server
**Plan, not built.** Written 2026-08-21.
Capture every route of a dashboard behind a JWT login, every night, on a machine nobody is sitting at —
and have an agent look only at what changed.
The target is **not** Officer and **not** this machine: a separate box running Officer with the `iris` and
`browser` plugins, pointed at a dashboard the owner runs elsewhere. Its login is a JWT, like Officer's.
## The shape
```
target server (Officer + iris + browser)
│
├─ cron, 03:00 ──▶ capture N routes with iris → runs/<date>/<route>.png
│ compare against runs/baseline/ → changed[]
│ if changed is empty → record and stop, wake nobody
│ else → open a chat turn with the changed pairs attached
│
└─ agent reads only the changed images, judges each: intended, or regression
→ notification with the verdict, and a link to the run
```
The expensive, fuzzy step happens only where pixels actually moved. On a quiet night the run costs a few
seconds of Chrome and nothing else.
## The hard part: a JWT lives in localStorage, and iris cannot put it there
This is the thing to solve first, because everything else is plumbing.
Cookie-authenticated sites work today: `iris-browser` copies `Cookies`, `Cookies-wal`, `Login Data` and
`Local State` out of a signed-in profile, so a capture rides the same session.
**A JWT dashboard is not cookie-authenticated.** The token sits in `localStorage`, which Chrome keeps in
`Default/Local Storage/leveldb/` — a directory `iris-browser` does not copy. So a capture of a
token-authenticated app renders the login screen, and does it silently: the screenshot is real, the page
is real, and it is the wrong page.
Three ways out, worth choosing between deliberately.
### A. Copy `Local Storage/leveldb` too — smallest change, biggest blast radius
One more entry in `seed_sessions`. It works, and it copies **every** site's localStorage into the
capture profile, not just the target's. That is someone's whole browser state in a temp directory, and
the directory is a LevelDB — copying it under a live browser can catch a compaction mid-write, which
Cookies-the-file mostly cannot.
Viable, and the least attractive of the three.
### B. Log in during the capture — no profile at all
Most JWT dashboards accept a token in the URL, or can be given one by a first-run page. If the target
does, then:
```
iris 'https://dash.example.com/?token=<jwt>' -o run/home.png
```
and there is no profile, no desktop, no signed-in browser anywhere. The token comes from a service
account minted for this, stored in Officer's secret store, and never appears in a screenshot.
**This is the recommended path** when the target supports it. It removes desktop from the picture
entirely, which removes the only reason that plugin was in this plan.
### C. Seed via the relay, capture via iris
Pair a browser once, use the relay to `evaluate` the token into `localStorage`, and let iris capture from
that profile. Works without changing either plugin, and needs a browser paired at 3am — which is exactly
the constraint iris exists to avoid.
Keep as the fallback for a target that supports neither a token URL nor a clean profile copy.
## What to capture
**Routes come from a list the repo owns**, not from crawling. A crawler finds what exists; a list says
what is supposed to exist, and a route disappearing is the regression you most want caught.
```jsonc
// routes.json, in the dashboard's own repository
[
{ "path": "/", "name": "home" },
{ "path": "/reports", "name": "reports", "waitFor": "[data-ready]" },
{ "path": "/reports/42", "name": "report-detail" },
]
```
`waitFor` maps to iris's `--wait-for`, which is what stops a spinner being the thing under test.
Two viewports — one desktop, one phone — doubles the run and catches the majority of layout breakage.
`--jobs` renders them in parallel.
## Diffing, before any agent is involved
Compare each capture with the same route's baseline. A pixel count, a threshold, nothing clever:
- **identical** → nothing happens
- **differs under threshold** (antialiasing, a clock, a random avatar) → nothing happens, but log it
- **differs over threshold** → into `changed[]`
The threshold exists because something on the page always moves. **Mask what is known to move** rather
than raising the threshold until it passes — a timestamp region is a rectangle to blank before comparing,
not a reason to accept 3% drift everywhere.
Officer has no image diff today. `sharp` is already a plugin dependency (atelier) and can do it; a raw
pixel loop over two decoded buffers is about thirty lines and needs nothing.
**Baselines are promoted, never automatic.** A run does not overwrite the baseline because it succeeded —
it does so when someone says "yes, that is the new correct". Otherwise a regression becomes the baseline
on night two and disappears.
## What reaches the agent
Only changed pairs, and each one as **two images plus the route name**: baseline and current. The
question asked is narrow, because a broad one gets a broad answer:
> This is `/reports` before and after. Describe what changed, then say whether it is a layout break, a
> content change, or noise. If you cannot tell, say so.
"If you cannot tell, say so" earns its place — a model asked to judge will judge, and a confident wrong
verdict on night four is how the whole thing stops being trusted.
The verdicts land in a notification (`officer-notify` is already core) with a count and a link, not the
images. Nobody wants twenty pictures on a phone at 3am.
## What does not exist yet
In rough order of how much they block:
1. **`--wait-for` against a JWT app** — solve auth first (§B), everything else is downstream.
2. **An image diff.** Thirty lines, or `sharp`.
3. **A scheduler.** Officer's queue has retries, not schedules — `scheduleRetry` is the only timed thing
in it. So this is system cron calling a script, or a `setInterval` in a plugin sidecar the way the
nudger does it. Cron is the honest answer for something that runs once a night.
4. **A place for runs and baselines.** `$OFFICER_ROOT/data/visual-regression/{baseline,runs}/` — outside
any plugin directory, because a plugin update replaces that.
5. **Whatever holds this.** A plugin of its own on that server, with `routes.json` as its config and a
panel showing the last run. It is not iris and it is not the relay; it is the thing that uses them.
## What this deliberately is not
**Not a test runner.** It proves a page renders and looks like it did yesterday. It cannot tell you a
button is wired to the wrong handler, and it should not be stretched to try — that is a real test suite,
and the two answer different questions.
**Not a crawler.** See routes, above.
**Not the browser relay.** The relay drives a live browser someone is signed into; this runs when nobody
is. They overlap only in that both produce screenshots. If §B holds, the relay has no role in this at all.
+16 -3
View File
@@ -1,15 +1,26 @@
{ {
"name": "officer", "name": "officer",
"version": "0.1.0", "version": "2026.08.23.04",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"src/databases/*", "src/databases/*",
"src/workspaces/*" "src/workspaces/*"
], ],
"engines": {
"bun": "1.3.x"
},
"scripts": { "scripts": {
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"", "preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
"gen:index": "bun run ./scripts/gen-index.ts", "gen:index": "bun run ./scripts/gen-index.ts",
"fix:env": "bun run ./scripts/fix-env.ts",
"todo:index": "bun run ./scripts/todo-index.ts",
"plugins:update": "bun run ./scripts/update-plugins.ts",
"nudge": "bun run --env-file=.env ./scripts/nudge-agent.ts",
"doctor": "bun run --env-file=.env ./scripts/doctor.ts",
"secret:set": "bun run --env-file=.env ./scripts/secret-set.ts",
"notify:test": "bun run --env-file=.env ./scripts/notify-test.ts",
"gen:ecosystem": "bun run --env-file=.env ./scripts/gen-ecosystem.ts",
"predev": "bun run ./scripts/gen-index.ts", "predev": "bun run ./scripts/gen-index.ts",
"dev": "bun --env-file=.env --watch src/server.tsx", "dev": "bun --env-file=.env --watch src/server.tsx",
"prestart": "bun run ./scripts/gen-index.ts", "prestart": "bun run ./scripts/gen-index.ts",
@@ -19,14 +30,16 @@
"build:dashboard": "bun run ./scripts/build/dashboard.ts", "build:dashboard": "bun run ./scripts/build/dashboard.ts",
"db:gen": "cd src/databases/officer_db && bun run generate", "db:gen": "cd src/databases/officer_db && bun run generate",
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push", "db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts", "postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
"db:migrate": "cd src/databases/officer_db && bun run migrate", "db:migrate": "cd src/databases/officer_db && bun run migrate",
"dev:emailer": "cd src/workspaces/emailer && bun run dev", "dev:emailer": "cd src/workspaces/emailer && bun run dev",
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write", "format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"", "format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/install.sh", "setup": "bash scripts/install.sh",
"check:theme": "bun scripts/check-theme-tokens.ts" "check:theme": "bun scripts/check-theme-tokens.ts",
"docs:check": "bun run scripts/docs-check.ts",
"skills:platform": "bun run scripts/gen-platform-skill.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41", "@anthropic-ai/claude-agent-sdk": "^0.2.41",
+92
View File
@@ -0,0 +1,92 @@
# Plugins
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
plugin author needs that are not procedure.
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
appears by `git clone` when somebody installs one, and is gitignored here.
---
## Affordances
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
the file format and how they reach the agent. Read it before writing one. What follows is only the part
that is specific to shipping one from a plugin.
> An affordance is something the surface on the other end of the conversation offers the agent — declared
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
> client will honour.
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
inside the file.
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
that it was a bug. See the two worked examples in `docs/affordances.md`.
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
have nothing to do with your plugin.
### A plugin-contributed one, for reference## Notifications — the same shape, for push
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
```
capabilities/notifications/
officerdev/music/
catalogue.yaml
```
```yaml
nowPlaying:
title: 'Now playing'
route: 'officer://music/{id}'
```
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
and the device renders the title and follows the route. The point is that **adding a notification is a
server change and never an app build**, because the mapping is synced to the device rather than compiled
into it.
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
catalogue is skipped without affecting anyone else's.
See `docs/push-notifications.md`.
### A plugin-contributed one, for reference
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
```markdown
---
name: Gitea
description: Use when working with git repositories, or with Gitea issues and pull requests.
version: 1
---
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
to the server directly.
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
configure it, and do not ask the user for a token.
```
### On the word
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
`capabilities/skills/` here and by the wider AI ecosystem.
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
other end permits. Keeping that distinction in the name saves explaining it every time.
+181
View File
@@ -0,0 +1,181 @@
# Kickoff prompt: extract Atelier into a plugin
Written 2026-08-20 for an **unattended overnight run**. That changes two things from
`EXTRACT-A-PLUGIN-PROMPT.md`, which assumes the owner is awake:
- **It does not say "ask and wait".** Nobody is there. Decisions that would normally be questions are
pre-made below, with reasoning, so the run does not stall on them.
- **It assumes you may be restarted.** Commit after every task, so whoever picks this up — including you,
an hour later, with no memory of it — finds a clean tree and a readable history.
Paste everything below the line into a fresh agent.
---
You are extracting **Atelier** out of `~/officer-dev/dockers/officer-atelier` and into an Officer plugin,
the way `music`, `offscale` and `gitea` were extracted before it.
**Work in a git worktree, not the main checkout.** Another agent is watching
`/home/pastilhas/officer-dev/platform` and pulling into it as you push. Two processes in one working tree
is how work gets lost — on 2026-08-20 it nearly did.
```
git -C /home/pastilhas/officer-dev/platform worktree add /home/pastilhas/officer-dev/atelier-work -b atelier-plugin
```
Everything below happens in `~/officer-dev/atelier-work`.
**Push every commit straight to master**, from the worktree:
```
git push origin HEAD:master
```
The branch is only there to keep your files out of the other checkout. Master is what is being reviewed.
Push after every task — a reviewer reading five small commits can tell you what went wrong on the second
one; nobody can do that with one commit at the end.
**Your final commit message must contain `ATELIER-COMPLETE` on its own line**, and no earlier commit may
contain that string. It is the signal that the run is finished and the review can start. Do not use it to
mean "this part is done".
## Read first, in this order
1. **`plugins/EXTRACTING-A-PLUGIN.md`** — the runbook and the authority. Read "What changed on
2026-08-16" carefully; it contradicts an earlier instruction in the same file, deliberately.
2. **`plugins/example/README.md`** — what a plugin is, as conventions.
3. **`src/servers/plugins/`** — `discover.ts`, `manifest.ts`, `mount.ts`, `install.ts`, `ecosystem.ts`.
Where this prompt and that code disagree, **the code is right**.
4. **`CLAUDE.md`** — house rules, the permission model, and why `assertPermissionTotality` refuses to boot.
5. **`docs/secret-store.md`** — Atelier holds an OpenAI key.
6. **`src/workspaces/officerdev/APP_CONVENTIONS.md`** and `HOOK_CONVENTIONS.md` — you are writing panels.
## What Atelier is
Image generation as a service: a provider-shaped API over OpenAI's image endpoints, storing what it makes
so every image has a permanent URL. Bun · Hono · Postgres/Drizzle · htmx with Hono JSX · a React SPA.
```
84 ts/tsx files, ~6,400 lines
tables collections, provider_settings (its own `atelier` database)
routes /generations, /generations/:id, /edits, /adapters,
/:id/items, /:id/items/:generationId, mask
env BUN_PORT, PUBLIC_URL, POSTGRES_URL, ATELIER_DATA_DIR,
OPENAI_API_KEY, ATELIER_MAX_CONCURRENCY
runs as pm2 `officer-atelier`, cwd ~/officer-dev/dockers/officer-atelier
```
It is a **sidecar** plugin for one reason that survives the UI moving into Officer: generation is
long-running, capped by `ATELIER_MAX_CONCURRENCY`, and holds a credential the platform must not. The UI
is Officer's; the work is the sidecar's.
## Decisions already made — do not re-open these
Each of these would normally be a question. They are answered so you can keep moving. If you find
evidence that one is wrong, **write it in `BLOCKERS.md`, follow the decision anyway, and carry on** — a
half-applied decision is worse than a wrong one consistently applied.
**1. The UI is PORTED into Officer. No proxy, no iframe, no sidecar-served UI.** Atelier was a separate
server because that was the quick answer at the time; this extraction is what undoes that. It becomes part
of Officer, not something Officer frames.
It is smaller than the repository suggests:
```
src/apps/frontend 1,736 lines / 25 files
6 of those components/ui/* — shadcn Officer ALREADY HAS. Delete, import from @/components/ui.
real work Generate · Gallery (+ Lightbox) · Collections · Providers ≈1,200 lines
src/apps/web the htmx half — 8 files, DELETED, not ported
```
So: three panel apps — **Generate**, **Gallery**, **Collections** — under
`plugins/atelier/web/`, each exporting `appRegistryMetas`, with a `layout.ts` composing them. Follow the
frontend route conventions in `CLAUDE.md`: selection lives in the URL, rows are real links, no
`usePanelChannel` for "which thing is open".
The fourth feature, **Providers**, is not a panel — see the next decision.
**2. Providers become Officer settings, with more than one.** `features/Providers/` is a settings screen,
and Officer has a settings surface already. It moves there rather than becoming a panel.
`src/servers/providers/` is already adapter-shaped — `types.ts`, `openai.ts`, `credentials.ts`, an
`index.ts` that dispatches. Keep that shape and keep the seam: today OpenAI is the only adapter, and the
point of moving the settings is that a second one is a new file rather than a new screen. **Do not add a
second provider in this run** — leave the shape able to take one and say so in `PLUGIN.md`.
**3. The database: check the rows, then decide.** Atelier uses a separate `atelier` Postgres database,
deliberately — `src/databases/atelier_db/drizzle.config.ts` says why. Plugin tables normally live in
`officer_db` with a prefix.
- Count the rows in `collections` and `provider_settings` first.
- **If both are empty**, fold them into `officer_db` as `atelier_collections` and
`atelier_provider_settings`, following `plugins/*/db/schema.ts`. Two tables do not earn a second
database.
- **If either has rows**, keep the separate database for now and record the migration as a follow-up in
`BLOCKERS.md`. Losing someone's generated images to a schema decision at 4am is not a trade worth making.
**4. Artefacts: `$OFFICER_ROOT/data/atelier/`.** Generated images are files, and `ATELIER_DATA_DIR`
currently points wherever its `.env` says. A plugin directory is a git clone that an update replaces, so
artefacts kept there vanish on upgrade with nothing logging it. `plugins/kiosk/artefacts.ts` has the same
problem solved and the comment explaining it.
**5. `OPENAI_API_KEY` goes in the secret store**, not `.env`. `putSecret`/`getSecret` landed 2026-08-19;
`bun run secret:set atelier-openai-key` puts it there. Read with `getSecret`, **never `getKey`** — `getKey`
mints a random value for an absent purpose, which for a foreign credential means silently authenticating
with a fake. Fall back to env when the store has nothing, so an install that predates this keeps working.
**6. One permission, `atelier`, `kind: 'app'`.** Image generation is not `execution` — it does not run as
the owner's OS user in the owner's home.
## How to work
**Map before you move.** The first task is not a file move. Find every line in the platform that names
atelier, and every import pointing INTO it. A plugin may import platform code (`@@/…`); the reverse cannot
happen. Report what you find before moving anything.
**Commit after every task, and push.** You may be restarted at any point by a scheduler that has no idea
what you were doing. A clean tree and a commit message are how the next attempt — possibly you — knows
where it is. Commit messages: simple lowercase, no prefixes.
**Do not restart the platform.** `pm2 restart officer` breaks the owner's chat session. Write the command
down instead; they will run it.
**Do not `git add -A`.** Add the paths you touched. This has caused two commits to swallow another
agent's work in one day.
**Format only what you touched** — `bunx prettier --write <paths>`, never `bun format`.
**`bunx tsgo` must be clean** before every commit. Not `tsc`.
## When you get stuck
Write it in `BLOCKERS.md` at the worktree root — what you were doing, what stopped you, what you did
instead, and whether it is reversible. Then **move to the next task**. Do not wait, do not invent a
workaround that changes behaviour without saying so, and do not stop the run.
If invented content ends up anywhere durable — copy, defaults, seed data — mark it `[placeholder]` so it
can be found later with a grep.
## Done means
- `plugins/atelier/` with `manifest.ts`, `api/router.ts`, `sidecar/index.ts`, `web/panels.ts`,
`web/layout.ts`, `db/`, `assets/icon.png`, `assets/splash.png`, and its own git repository.
- It installs from the Marketplace onto a machine that has never had it, and the panel renders.
- `bunx tsgo` clean, `bun test` passing.
- A `PLUGIN.md` in the plugin recording what moved, what did not, and why — the way `music` and `offscale`
did. This is the part that is worth most in three months.
- `BLOCKERS.md` listing every decision you made without being able to ask.
## What you may NOT do
- No unauthenticated routes. A plugin cannot serve one; `UNPROTECTED_API_PREFIXES` is hardcoded in the
platform. If Atelier needs one, that is a blocker, not something to work around. (`plugins/kiosk`
is stuck on exactly this — read its README before concluding you have found a way.)
- **Generated images are served through an authenticated route**, like every other plugin artefact. Every
image having "a permanent URL" was true of a standalone server with its own `PUBLIC_URL`; inside Officer
the URL is behind the session. If something needs an image without one — an `<img>` in an email, a
share link — that is a blocker to write down, not a route to open. See the image-rendering affordance
in `capabilities/affordances/officer/core/` for why an `<img>` cannot carry a token.
- No changes to `src/servers/plugins/` to make Atelier fit. If the contract does not accommodate it, that
is a finding worth more than the workaround.
- No second agent, no second worktree, no background job that writes to this tree.
+32 -14
View File
@@ -78,9 +78,11 @@ Music added the field; see its PLUGIN.md for what it is guarding against.
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing **Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
anywhere else means two systems, and only one gets tested. anywhere else means two systems, and only one gets tested.
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the **Only an explicit tick destroys data.** The generated schema barrel follows plugin **directories**, not
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables the install table — `db:push` drops what it cannot see, so following installs would delete a plugin's
on uninstall. Only deleting a plugin's source can lose its data. tables the moment it was stopped. Deleting a plugin removes its directory, and the confirmation's
"also delete all its data" checkbox is the one thing that drops its tables. Unticked, the rows outlive
the plugin and show up in the orphan list on the same screen.
--- ---
@@ -125,9 +127,9 @@ Then through `/plugins`, watching PM2 and the browser at each step:
| permissions page | its permission is listed, read/write/none | | permissions page | its permission is listed, read/write/none |
| disable | route 404s, sidecar stops, **tables and rows survive** | | disable | route 404s, sidecar stops, **tables and rows survive** |
| enable | comes back | | enable | comes back |
| uninstall | route gone, `pm2 list` loses it, **data still there** | | delete, checkbox unticked | route gone, `pm2 list` loses it, directory gone, **data still there** |
| `bun db:push` while uninstalled | **do not run this — see "What changed on 2026-08-16"** | | install again | fresh clone, and it finds those rows |
| install again | identical to the first install | | delete, checkbox ticked | tables dropped too — a machine that never had it |
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists. A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
@@ -159,6 +161,16 @@ A normal refresh is enough; the shell is `no-store`. When the log's last line ap
module cache holds it for the lifetime of the process — so editing a manifest while developing changes module cache holds it for the lifetime of the process — so editing a manifest while developing changes
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either. cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
- **A router that proxies BY HAND must call `registerProxiedPrefix`.** `bodyParser` runs on the whole
protected router and reads JSON and form bodies, which DISTURBS the request stream — so a catch-all that
then forwards `ctx.req.raw.body` forwards an already-consumed stream, Bun's fetch throws, and the proxy
reports the sidecar as unreachable. Every bodyless GET keeps working, so it presents as "the sidecar is
down" for POSTs only, which is a long way from the cause. `createSidecarProxy` registers the prefix
itself, so core sidecars and any plugin using it never meet this; vault proxies by hand and its
extraction dropped the registration on 2026-08-18, costing an afternoon. If your plugin hand-rolls a
proxy: `registerProxiedPrefix(PREFIX)` beside the mount, and check no handler under it reads
`ctx.get('body')`.
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports - **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
it. Find those before you plan the split; they decide it for you. it. Find those before you plan the split; they decide it for you.
@@ -210,15 +222,21 @@ no terminal it applies NOTHING and exits 0, so an install reports success and th
query with 42P01. `--force` would be worse if it worked: the destructive statements are `drop table` for query with 42P01. `--force` would be worse if it worked: the destructive statements are `drop table` for
every plugin whose directory is absent. every plugin whose directory is absent.
So: the row in the verification table that says to run `bun db:push` while uninstalled is struck out. The So: the row in the verification table that said to run `bun db:push` while stopped is gone. The property
property it was checking — uninstall does not destroy data — is now checked by reinstalling and finding it was checking — stopping a plugin does not destroy data — is now checked by deleting it with the
your rows still there. checkbox unticked, installing again, and finding your rows still there.
**Use `purge` to test the install twice.** Uninstall deliberately keeps the directory and the tables, so a **Delete WITH the checkbox to test the install twice.** Anything less is not a fresh install: the
second install is not a fresh one: the marketplace hides a plugin whose directory exists, and the tables marketplace hides a plugin whose directory exists, and tables that are already there make `db:push` a
are already there. The Marketplace screen has "Delete <plugin> and all its data" — it drops the tables, no-op, so a broken schema stays broken and passes. Ticked, it drops the tables, deletes the directory and
deletes the directory and regenerates the barrel, and afterwards the plugin installs from the catalogue regenerates the barrel — and afterwards the plugin installs from the catalogue exactly as a stranger's
exactly as a stranger's machine would. **That is the only honest test of an install.** machine would. **That is the only honest test of an install.**
**Uninstall was removed on 2026-08-21.** It sat between disable and delete, keeping the directory and the
data, and the screen could not show the difference: an uninstalled plugin stayed in the list under a
heading that said "Installed" while its directory silently decided what the next install would run. That
is not hypothetical — it is how a jellyfin install on alpha re-applied a checkout from two days earlier,
reporting success at every step. The verbs are now enable, disable and delete.
**The install now verifies its own tables.** After the push it asks Postgres whether each declared table **The install now verifies its own tables.** After the push it asks Postgres whether each declared table
exists and fails the install naming what is missing. If your extraction reports "schema push reported exists and fails the install naming what is missing. If your extraction reports "schema push reported
+240
View File
@@ -0,0 +1,240 @@
# Provisioning a user in a plugin's upstream service
> **Handing this to a fresh agent?** Everything below was verified against a live instance on
> 2026-08-19 — every status code, every error body. Trust it over the upstream's own docs, which
> lag. Re-verify with the probe sequence at the end if the upstream has been upgraded since.
Officer creates the account; the upstream service owns it. This is the contract for doing that
safely, written from the **`photos` plugin** (Immich) as the first worked example. The shape
generalises: authenticate as an admin, check-then-create, verify the result, hand over a one-time
password.
**This directory holds documentation, not plugins.** The plugin itself lives in
[`plugins/photos`](https://gitea.officer.dev/plugins/photos).
---
## The four rules
1. **Never hardcode the admin credential.** Read it from the environment or the platform's
credential store, and never log it. Docs and repos get shared; credentials must not travel
with them.
2. **Check before you create.** None of these APIs upsert. A second call with the same email is
an error, not a no-op — so provisioning must be re-runnable by listing first.
3. **Force a password change.** Generate a strong random password, mark it as
must-change, and treat it as a one-time handover value. It stops being a secret the moment
the user logs in.
4. **Verify by logging in.** A `201` means the row was written, not that the account works. Do a
real login before reporting success, so failures surface at provisioning time rather than when
the person first tries to use it.
---
## Worked example — Immich (`photos` plugin)
Verified against **Immich 3.1.0**.
| | |
|---|---|
| API root | `<base>/api` — e.g. `http://127.0.0.1:2283/api` |
| Auth | header `x-api-key: <ADMIN_API_KEY>` |
| Admin routes | `/api/admin/*` — reject non-admin keys with `401` |
Immich stores API keys **SHA-256 hashed**, so a lost key can only be replaced, never recovered.
Mint one in the UI → Account Settings → API Keys.
### 1. Check (idempotency guard)
```http
GET /api/admin/users
x-api-key: <ADMIN_API_KEY>
```
→ `200`, JSON array. If the target email is present, stop — already provisioned.
### 2. Create
```http
POST /api/admin/users
x-api-key: <ADMIN_API_KEY>
Content-Type: application/json
{
"email": "person@example.com",
"password": "<generated>",
"name": "Full Name",
"shouldChangePassword": true
}
```
**Required:** `email`, `password`, `name`.
| Optional field | Type | Notes |
|---|---|---|
| `shouldChangePassword` | bool | `true` ⇒ forced reset at first login. Always send it. |
| `quotaSizeInBytes` | int | e.g. `1073741824` = 1 GiB. Omit for unlimited. |
| `storageLabel` | string | Folder name used by the storage template. |
| `notify` | bool | Emails the new user. Requires SMTP configured upstream. |
→ **`201 Created`**, body carries the new `id`:
```json
{ "id": "70ee4cd2-…", "email": "person@example.com", "name": "Full Name",
"isAdmin": false, "shouldChangePassword": true, "quotaSizeInBytes": null }
```
`isAdmin` cannot be set at creation — promote afterwards via the admin update endpoint.
Names are full Unicode (`Bruno Conceição`); send UTF-8, no escaping.
### 3. Verify
```http
POST /api/auth/login
Content-Type: application/json
{ "email": "person@example.com", "password": "<generated>" }
```
→ `200` with `{"name":…,"userEmail":…,"isAdmin":false,"shouldChangePassword":true}`.
No API key — this is the ordinary user login path, which is exactly why it proves the account works.
### Errors (observed, not guessed)
| Condition | Status | Body |
|---|---|---|
| Email already exists | `400` | `{"message":"Email is not available"}` |
| Missing `password` / `name` | `400` | `{"message":"Validation failed","errors":[{"path":["password"],…}]}` |
| Bad or missing API key | `401` | — |
### Deprovisioning
```http
DELETE /api/admin/users/{id}
Content-Type: application/json
{ "force": true }
```
→ `200` with `{"status":"removing","deletedAt":"…"}`. Soft-delete; `force` skips the grace period.
---
## Worked example — Jellyfin (`jellyfin` plugin)
Same four rules, **different shape**. Read this section as: the route surface and auth model are
**verified** against Jellyfin **10.11.11**; the request bodies are from working knowledge and
marked as such — confirm them with the probe recipe below before relying on them in code.
### How it differs from Immich — read this first
| | Immich | Jellyfin |
|---|---|---|
| Auth header | `x-api-key: <key>` | `Authorization: MediaBrowser Token="<key>"` |
| Create + configure | one call | **two or three** — create, then policy, sometimes password |
| Force password change | `shouldChangePassword: true` | **no equivalent** — see below |
| Scope of an account | one server | **per instance** — see below |
Two consequences that bite:
1. **There is no "must change password" flag.** Rule 3 cannot be enforced by the server.
**Decision for this deployment (2026-08-20): set a generated password at creation and tell the
user to change it.** Do *not* use the blank-password alternative — Jellyfin permits it, but the
account is then open to anyone who can reach the port until the user sets one.
So for Jellyfin, rule 3 becomes: still generate a strong unique password, still treat it as a
handover value, but the change is advisory rather than forced — which means **never reuse a
generated password across users or instances**, since it may stay valid indefinitely.
2. **Users are per-instance, not global.** This deployment runs **four** independent Jellyfin
servers, each with its own user database and its own API keys:
| Instance | Port | Server name |
|---|---|---|
| `jellyfin` | 9301 | Jelly |
| `jellyfin_music` | 9302 | Albums |
| `jellyfin_dj_sets` | 9303 | DJ_Sets |
| `jellyfin_diogo` | 9399 | JellyDiogo |
Provisioning "a Jellyfin user" therefore means looping over whichever instances that person
should reach, with a **separate API key per instance**. There is no cross-server account.
### Getting an API key
Unlike Immich, Jellyfin has **no admin-token bootstrap from nothing** — creating a key requires
an already-authenticated session. Two routes:
- **UI:** Dashboard → API Keys → `+`. Simplest, and the one to use for first setup.
- **API:** `POST /Auth/Keys?App=<name>` (verified: route exists, `401` unauthenticated), using a
token obtained from `POST /Users/AuthenticateByName` with an admin username/password
(verified: route exists; returns `415` if you omit the JSON content type).
`GET /Auth/Keys` lists them (verified: exists, `401` unauthenticated). Keys are stored in
`config/data/jellyfin.db`, table `ApiKeys`. **This deployment currently has zero API keys** — one
must be created before any of the flow below will work.
### The flow
```http
GET /Users
Authorization: MediaBrowser Token="<API_KEY>"
```
→ verified: exists, `401` without auth. Use it as the **check** step (rule 2) — match on `Name`.
```http
POST /Users/New
Authorization: MediaBrowser Token="<API_KEY>"
Content-Type: application/json
{ "Name": "Full Name", "Password": "<generated>" }
```
→ verified: route exists (`401` unauthenticated). Body shape from working knowledge. Returns the
created user object including its `Id` (a GUID), which the next calls need.
Then, as needed:
| Purpose | Endpoint | Notes |
|---|---|---|
| Library access, admin rights, parental rating | `POST /Users/{id}/Policy` | Full policy object; **send the whole thing**, it replaces rather than merges. This is where `IsAdministrator` and `EnabledFolders` live. |
| Set/replace password | `POST /Users/{id}/Password` | Needed if you created the user without one. |
| Delete | `DELETE /Users/{id}` | Hard delete — no soft-delete/grace period, unlike Immich. |
**Verify (rule 4)** with a real login rather than trusting the create:
```http
POST /Users/AuthenticateByName
Content-Type: application/json
{ "Username": "Full Name", "Pw": "<generated>" }
```
→ returns an `AccessToken` and the `User` object on success. Note the field is `Pw`, not
`Password`, and Jellyfin authenticates by **username**, not email — Jellyfin has no concept of an
email login.
### A public endpoint worth knowing
`GET /Users/Public` needs **no authentication at all** (verified: `200`). It lists users flagged
as visible on the login screen. On this deployment it returns `[]` on all four instances, so
nothing is currently exposed — but if you provision users, be aware that leaving them
login-screen-visible publishes their names to anyone who can reach the port.
### Probe recipe (Jellyfin)
Same spirit as the Immich one, but you need a key first, and there is no soft-delete — so probe on
a **non-production instance** or be prepared to hard-delete:
1. `GET /Users` with the key → `200` and an array. Wrong key → `401`.
2. `POST /Users/New` with a throwaway name → expect the created object; keep the `Id`.
3. Repeat the identical call → observe the duplicate-name behaviour and **record the exact status
and body here**, since it is the guard rule 2 depends on.
4. `POST /Users/AuthenticateByName` with the throwaway credentials → expect an `AccessToken`.
5. `DELETE /Users/{id}` → then `GET /Users` to confirm it is gone.
---
## Re-verifying after an upstream upgrade
Cheap and non-destructive — create a probe user on an unroutable domain, exercise it, delete it:
1. `POST /api/admin/users` with `probe-delete-me@example.invalid` → expect `201`, keep the `id`.
2. Repeat the identical call → expect the duplicate-email `400`.
3. `POST` with `password`/`name` omitted → expect the validation `400`.
4. `POST` with a bogus key → expect `401`.
5. `DELETE /api/admin/users/{id}` with `{"force":true}` → expect `200`, then `GET` to confirm it's gone.
Use `.invalid` (RFC 2606 — guaranteed never to resolve) so a stray notification email can never
reach a real person.
+318
View File
@@ -0,0 +1,318 @@
# Deploy commander
Start a commander with this. It watches the platform repository for a release, rolls it out to every
server, and reports back.
**It never decides what a deploy needs.** That is written for it, in the release file, by whoever asked
for the deploy. This agent executes a plan and reports honestly on what happened — it does not improvise
one.
---
## Arming for the first time — there is nothing to rehearse against
General commander doctrine says to run the action once before arming, so it is known to work unattended.
**That does not apply here, and reading it as "replay the last release" is wrong.**
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
release, which you must never do.
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
fights the claim or invents an occasion.
Two things follow from it, and they are the reason this is fine rather than a gap:
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
What is unproven is this loop, not the rollout.
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
## What wakes you
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
there is a release to deploy that has not been deployed.
```bash
git ls-remote --tags origin 'refs/tags/deploy/*'
```
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
long stretches.
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
already trusted.
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
a commit message, not a branch, not a push.
**Why a tag and not a code word in a commit.** A code word can be written by accident. Every document
about deploying contains it, this file included, and so does every conversation planning one — so the
trigger has to be defended with rules about where in the subject line it sits, and a rule like that is
one somebody eventually gets wrong. A tag cannot be typed into prose. It is created deliberately, it
names its own version, and `git ls-remote` answers "is there one" in a single call without fetching.
- **No untagged release** → say "no release pending" and stop. Do not pull, do not touch a server.
- **A `deploy/*` tag with no local record** → that is the release. Continue.
### One attempt per release, and you do not enforce that yourself
**Before you spawn or run anything for a version, claim it:**
```bash
scripts/deploy-claim.sh claim <version> # exit 0 = it is yours, exit 1 = do not proceed
```
Exit 1 means somebody already attempted this release. **Stop. Do not retry it, do not deploy it, and do
not decide the previous attempt does not count.** Say what the script said and move on.
On success, record it through the same script:
```bash
scripts/deploy-claim.sh done <version>
```
**Why this is a script and not a rule you follow.** A watcher loop is written fresh each time one arms, so
a rule that lives only in this file gets re-implemented from scratch every time — and the wrong version of
it looks right: test for the `.done` file, which exists only on SUCCESS. Write that and a failing release
spawns a new agent every poll, all night, each one costing money. That is not hypothetical. It is what the
first watcher did on 2026-08-21: a careful loop, honest logging, and an unbounded retry inside it.
So the rule is code. `mkdir` is the claim — atomic, one syscall that both tests and takes, no window
between checking and acting. The script refuses a second claim and keeps refusing, whatever happened to the
first attempt.
**A failed release is therefore not retried. It is looked at.** Clearing a claim is a deliberate human act
(`rm -rf …/<version>.claim`) or a new tag. A loop cannot undo its own claim, which is the property that
makes the storm impossible rather than unlikely.
### The record is LOCAL, not in git
```
$OFFICER_ROOT/data/deploys/<version>.done
```
That file's existence means deployed. Written after the rollout, before the report is pushed.
**This is deliberate and it is the important part.** The obvious design makes the pushed report the
marker — and then a failed `git push` means the release looks undeployed and the next tick **does it all
again**. Pushes here are genuinely unreliable: gitea's public SSH is rate-limited because every
connection arrives from one proxy container IP and internet bots get it penalised, which cost several
retries on 2026-08-21.
So the lock is local and cannot fail for a network reason. The report is an ARTEFACT — valuable, and not
load-bearing. A missing report is a missing record; a missing lock is a second deploy.
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
clone does not arrive believing it has deployed nothing.
## Before a release is tagged — not your job, but know it happened
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
thirteen, listing eleven processes that do not run here while omitting six that do.
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
it would put a prose edit between two hosts.
## The release
The tag names the version. The commit it points at carries the rest:
- `package.json` — `version` matching the tag, `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps'
scheme. `NN` increments for a second deploy on the same day.
- `deploy/<version>.md` — **the plan**. What to run, in order, and on which hosts. Written by hand.
If the tag and `package.json` disagree, **stop and say so**. One of them is a mistake and guessing which
is how the wrong tree gets deployed.
If the plan file is missing, **stop and say so**. A release with no plan is not a release you can guess
at, and guessing is how a database gets a schema it was not ready for.
---
## The servers
**Read `docs/servers.md`.** Do not take a list of hosts from this file — there is deliberately none here,
because a copy rots the day a server moves and the copy is always the one somebody trusts.
That document holds every machine, its tailnet name, **its login user**, its checkout and whether a
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
platform as out of scope.
It also has a **Deliberately skipped** section. A host listed there is not a target: name it as skipped
in the report and spend nothing on it. On 2026-08-23 a host that was known to be down cost four attempts
and a minute apiece before being reported unreachable — correct behaviour against a list that had not been
told. Read that section before you try to reach anything.
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
Four things that will catch you out, all verified rather than assumed:
- **Never hard-code the path.** Take it from PM2: the `officer` process's `pm_cwd`. `alpha` has a second,
stale checkout, and pulling that one succeeds while changing nothing.
- **`pm2` is not on a non-interactive PATH.** It is installed under nvm, and nvm lives in `.zshrc`, which
a _login_ shell does not read. Use `ssh <host> '$SHELL -ic "pm2 jlist"'`. `-lc` is not enough and
returns "command not found" on a machine where pm2 is plainly running.
- **The git remotes differ.** `fofos` pulls over HTTPS; others over SSH. Never assume a URL — just
`git pull --ff-only` in place.
- **`macbookpro` is macOS and is a laptop.** It may be asleep. Unreachable is a SKIP with a note, not a
failure of the deploy.
---
## Rolling it out
Per host, in this order. Do them **one host at a time** and keep a record as you go — a report you
assemble at the end from memory is a report that will be wrong about the host that failed.
1. **Refuse a dirty tree — TRACKED changes only.** `git status --porcelain --untracked-files=no`. Plain
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
discard someone's work to ship a release.
2. **`git fetch origin --tags` then `git merge --ff-only <tag>`** — the TAG you are deploying, not
`master`. `git pull --ff-only` follows the branch, so a commit pushed while you are rolling out lands
on some hosts and not others, and "deployed the tag" stops being true of any of them. That happened on
2026-08-23: every host came up one commit past the tag because a docs commit landed mid-rollout. It was
harmless and it need not have been.
A non-fast-forward means that checkout has diverged: skip and report. A tag that will not fast-forward
because the host is AHEAD is the same answer — report it, do not reset.
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
checkout.
4. **Run the plan's commands** for that host, in the order given. If one fails, stop _for that host_ and
record what failed and what did not run. Do not continue past a failed `bun install` or `db:push`.
5. **Restart what the plan names**, and nothing else. Never `pm2 restart ecosystem.config.cjs` — it
bounces every sidecar including the vault.
6. **Check it came back.** `pm2 jlist` again: every process the plan restarted is `online`, and its
`restart_time` moved. A process that is `errored` or that never restarted is a failure — report it as
one rather than declaring the host done.
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
one that stopped in the middle and said nothing about the four hosts it never reached.
### Schema changes, and why `db:push` cannot be trusted
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
success while the schema is half-applied.
So on any release whose plan says the schema changed:
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
a guess.
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
table empty and adds the constraint in the same run, so every existing row violates it. There is no
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
This is allowed and it is not a licence to improvise. The rules, in order:
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
that is a code change and it is not yours to make on five production hosts at 3am.
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
the fleet has diverged in a way nothing will detect.
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
forensic exercise.
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
finding for the report rather than something to push past.
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
the specific check, and its answer, for each.
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
repair you invented.
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
### Skip the host you are running on
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
You are a session inside `officer-claude-code` on that machine. Restarting it ends you mid-rollout —
which is not a hypothetical: on 2026-08-21 a manual deploy reached its own host last and had to leave
that one sidecar alone to survive writing its own report.
Restarting only `officer` and leaving your own sidecar behind would work, and it is worse: it puts one
host in a state nobody chose, a step behind the others, discovered later. So skip the whole host and say
which one. The owner restarts it deliberately, as the last step, once they are happy with the rest.
Find it with `hostname` and match it against the list — do not assume you are on any particular one.
---
## The report
Write the local record FIRST — `$OFFICER_ROOT/data/deploys/<version>.done` — then commit
`deploy/<version>.report.md` and push it.
That order matters. The record is what stops a second deploy, and the push is the part that can fail for
reasons having nothing to do with the rollout. **If the push fails, say so and stop — do not retry into a
loop and do not treat it as a failed deploy.** The deploy succeeded; its paperwork is pending.
Never tag the report. A tag is the trigger, and a report that tags itself deploys itself forever.
Per host: what its HEAD was before and after, which steps ran, which were skipped and why, which
processes restarted, and whether they came back. Then one line at the top saying how many hosts
succeeded out of how many.
Say what you did not do. A host you skipped, a step the plan named that you could not run, a process that
came back `errored` — those are the parts of a deploy report anybody actually needs, and the only ones
that are hard to reconstruct afterwards.
---
## Being woken
A background job does it, armed from the composer's background-job dialog with **Exit and hand back to the
agent, then re-arm**. The wait happens in the shell, not in a model tick — a loop polling
`git ls-remote`, which is one network call and no tokens.
The loop cannot do a deploy: it cannot read a plan, judge a host or write a report. So on firing it spawns
a headless agent — `claude -p "<read this file and follow it>" --dangerously-skip-permissions
--output-format=stream-json --verbose` — and pipes the events through `jq` so the job panel shows progress
instead of a blank box for twenty minutes.
Two things that loop must get right, both learned by getting them wrong on 2026-08-21:
- **Claim the version first**, per the section above. This is the one that costs money when missed.
- **Set `OFFICER_ROOT` explicitly.** It is empty in the sidecar's environment, so the lock path resolves to
`/data/deploys` and the release looks permanently undeployed.
The deploy therefore runs in a **separate** agent, not in the commander's own conversation: no Command HQ
row, no cost on the commander's line, and its reasoning lives in that child's transcript. That is the price
of putting the wait in the shell, and it is worth it — a model-side tick that re-reads a whole conversation
to learn nothing is the thing being avoided.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+8 -7
View File
@@ -1,4 +1,4 @@
<svg width="1200" height="240" viewBox="-20 40 1200 260" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="900" height="300" viewBox="60 40 900 300">
<defs> <defs>
<filter id="shadow3d-main-officer"> <filter id="shadow3d-main-officer">
<feOffset dx="6" dy="6" in="SourceGraphic" result="offset1"/> <feOffset dx="6" dy="6" in="SourceGraphic" result="offset1"/>
@@ -25,25 +25,26 @@
</pattern> </pattern>
<mask id="textMaskOfficer"> <mask id="textMaskOfficer">
<text x="580" y="220" text-anchor="middle" class="officer-text-main" fill="white">o<tspan dx="6" dy="30" letter-spacing="14">ff</tspan><tspan dx="-2" dy="-30">icer.dev</tspan></text> <text x="510" y="220" text-anchor="middle" class="officer-text-main" fill="white">Of<tspan dx="16">f</tspan><tspan dx="10">i</tspan><tspan dx="10">c</tspan><tspan dx="10">e</tspan><tspan dx="10">r</tspan></text>
</mask> </mask>
<style> <style>
.officer-text-main { .officer-text-main {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, "System Font", ".SF NS", "Helvetica Neue", sans-serif;
font-size: 140px; font-size: 140px;
font-weight: 900; font-weight: 700;
fill: #F4C430; fill: #F4C430;
stroke: #14532D; stroke: #14532D;
stroke-width: 3; stroke-width: 3;
paint-order: stroke fill; paint-order: stroke fill;
letter-spacing: -2px; letter-spacing: 8px;
text-transform: lowercase; font-variant-ligatures: none;
font-feature-settings: "liga" 0, "clig" 0, "dlig" 0;
} }
</style> </style>
</defs> </defs>
<text x="580" y="220" text-anchor="middle" class="officer-text-main" filter="url(#shadow3d-main-officer)">o<tspan dx="6" dy="30" letter-spacing="14">ff</tspan><tspan dx="-2" dy="-30">icer.dev</tspan></text> <text x="510" y="220" text-anchor="middle" class="officer-text-main" filter="url(#shadow3d-main-officer)">Of<tspan dx="16">f</tspan><tspan dx="10">i</tspan><tspan dx="10">c</tspan><tspan dx="10">e</tspan><tspan dx="10">r</tspan></text>
<rect x="0" y="0" width="1160" height="300" fill="url(#gridPatternOfficer)" mask="url(#textMaskOfficer)"/> <rect x="0" y="0" width="1160" height="300" fill="url(#gridPatternOfficer)" mask="url(#textMaskOfficer)"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
#
# One attempt per release, enforced by the filesystem.
#
# A watcher loop asks this before it spawns anything. Exit 0 means "you have the claim, go". Exit 1 means
# "somebody already had it" — and that answer never changes back, whatever happened to the attempt that
# held it. A release that fails is not retried; it is looked at.
#
# WHY THIS IS A SCRIPT AND NOT A PARAGRAPH IN prompts/deploy-commander.md
#
# The watcher loop is written fresh by an agent each time it arms, so a rule that lives only in prose is
# re-implemented from scratch on every arming, and "skip a version already attempted" has a wrong version
# that looks right: test the .done file, which only exists on SUCCESS. Write that and a failing deploy
# spawns a fresh agent every poll, all night, each one costing money. That is exactly what happened on
# 2026-08-21 — the loop was careful, logged honestly, and still had an unbounded retry in it.
#
# WHY BASH AND NOT bun
#
# The thing that stops a retry storm must not depend on the checkout being healthy. A deploy that dies on
# `bun install` is precisely when this has to work.
#
# WHY mkdir
#
# It is atomic on every POSIX filesystem and it fails if the target exists — one syscall that both tests
# and claims. `[ -f x ] && ...` is two steps with a race between them, and two pollers a second apart is
# not a hypothetical when a watcher is restarted.
set -u
DEPLOYS_DIR="${DEPLOYS_DIR:-$HOME/officer-dev/data/deploys}"
usage() {
cat >&2 <<'USAGE'
usage: deploy-claim.sh claim <version> take the one attempt for this release (exit 0 = yours, 1 = taken)
deploy-claim.sh done <version> record that the attempt succeeded
deploy-claim.sh state <version> print unclaimed | claimed | done
deploy-claim.sh list every release this host knows about
To retry a release deliberately: rm -rf <deploys-dir>/<version>.claim
That is a human act on purpose. A loop cannot undo its own claim.
USAGE
exit 2
}
version_ok() {
# The version reaches here from a git tag, so it is attacker-adjacent input used to build a path.
case "$1" in
*[!0-9.]* | '' | *..*) return 1 ;;
*) return 0 ;;
esac
}
CMD="${1:-}"
[ -n "$CMD" ] || usage
case "$CMD" in
list)
mkdir -p "$DEPLOYS_DIR"
found=0
for d in "$DEPLOYS_DIR"/*.claim; do
[ -e "$d" ] || continue
found=1
v=$(basename "$d" .claim)
if [ -f "$DEPLOYS_DIR/$v.done" ]; then s="done"; else s="claimed, no success recorded"; fi
printf '%-16s %s\n' "$v" "$s"
done
[ "$found" = 1 ] || echo "no releases attempted on this host"
;;
claim | done | state)
V="${2:-}"
[ -n "$V" ] || usage
version_ok "$V" || { echo "deploy-claim: refusing a version that is not digits and dots: $V" >&2; exit 2; }
mkdir -p "$DEPLOYS_DIR" || exit 2
CLAIM="$DEPLOYS_DIR/$V.claim"
DONE="$DEPLOYS_DIR/$V.done"
case "$CMD" in
state)
if [ -f "$DONE" ]; then echo done
elif [ -d "$CLAIM" ]; then echo claimed
else echo unclaimed; fi
;;
claim)
# A pre-existing .done with no .claim is a release deployed before this script existed. Honour it.
if [ -f "$DONE" ]; then
echo "deploy-claim: $V is already deployed" >&2
exit 1
fi
if mkdir "$CLAIM" 2>/dev/null; then
printf '%s claimed on %s by pid %s\n' "$(date -Iseconds)" "$(hostname)" "$$" > "$CLAIM/by"
echo "deploy-claim: $V is yours"
exit 0
fi
printf 'deploy-claim: %s was already attempted — %s\n' "$V" "$(cat "$CLAIM/by" 2>/dev/null || echo 'claim exists')" >&2
echo "deploy-claim: NOT retrying. Look at what failed, then rm -rf $CLAIM to allow another attempt." >&2
exit 1
;;
done)
[ -d "$CLAIM" ] || echo "deploy-claim: warning — recording success for $V that was never claimed" >&2
printf '%s succeeded on %s\n' "$(date -Iseconds)" "$(hostname)" >> "$DONE"
echo "deploy-claim: $V recorded as deployed"
;;
esac
;;
*) usage ;;
esac
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const SCRIPT = join(import.meta.dir, 'deploy-claim.sh');
let dir: string;
const run = (...args: string[]) => {
const p = Bun.spawnSync([SCRIPT, ...args], { env: { ...process.env, DEPLOYS_DIR: dir } });
return { code: p.exitCode, out: p.stdout.toString(), err: p.stderr.toString() };
};
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'deploy-claim-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
// The whole point of this script is that the SECOND caller is refused. Everything else is scaffolding.
describe('deploy-claim', () => {
it('grants once and refuses after, whatever the outcome was', () => {
expect(run('claim', '2026.09.01.01').code).toBe(0);
expect(run('claim', '2026.09.01.01').code).toBe(1);
// Still refused after a failure — no .done was ever written. This is the retry storm not happening.
expect(run('claim', '2026.09.01.01').code).toBe(1);
expect(run('state', '2026.09.01.01').out.trim()).toBe('claimed');
});
it('refuses a release already deployed, even with no claim beside it', () => {
// A release deployed by hand before this script existed. `deploy/2026.08.21.01` is exactly this case.
writeFileSync(join(dir, '2026.08.21.01.done'), 'deployed by hand\n');
expect(run('claim', '2026.08.21.01').code).toBe(1);
expect(run('claim', '2026.08.21.01').err).toContain('already deployed');
});
it('lets a human hand the claim back, and only a human', () => {
expect(run('claim', '2026.09.01.02').code).toBe(0);
rmSync(join(dir, '2026.09.01.02.claim'), { recursive: true });
expect(run('claim', '2026.09.01.02').code).toBe(0);
});
it('records success separately from the claim', () => {
run('claim', '2026.09.01.03');
run('done', '2026.09.01.03');
expect(run('state', '2026.09.01.03').out.trim()).toBe('done');
expect(existsSync(join(dir, '2026.09.01.03.done'))).toBe(true);
});
// The version comes off a git tag, and it is concatenated into a path.
it('refuses anything that is not digits and dots', () => {
for (const bad of ['../../etc/passwd', 'x; rm -rf /', '2026.08.21.01/..', '', 'v1']) {
expect(run('claim', bad).code).toBe(2);
}
expect(existsSync(join(dir, '..', 'etc'))).toBe(false);
});
it('says so plainly when nothing has been attempted', () => {
expect(run('list').out).toContain('no releases attempted');
});
});
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# The wait half of the deploy commander: poll for a release tag, and when one appears that has not been
# attempted, spawn a headless agent to deploy it.
#
# WHY THIS IS IN THE REPOSITORY AND NOT WRITTEN FRESH EACH ARMING
#
# prompts/deploy-commander.md says the watcher loop is rewritten from scratch every time one arms, and
# treats that as a given. It is also how the same defect keeps arriving. The loop that was on disk at
# data/deploy-watch.sh from 2026-08-21 gated on "$v.done" — a file that exists only on SUCCESS — and never
# called deploy-claim.sh at all. That is precisely the retry storm the claim script was written to make
# impossible, reintroduced in the one place the claim script cannot reach: the caller that decides whether
# to invoke it. A rule enforced by a script nobody calls is not enforced.
#
# So the loop lives next to the claim it depends on, and changes with it.
#
# THE BASELINE IS THE CLAIM, NOT THE SUCCESS
#
# A watcher needs to know what counts as new. Here that is "no claim directory for this version" — a record
# of what has been HANDLED, not of what has SUCCEEDED. The distinction is the whole point: a release that
# failed is handled. It is looked at by a human, not retried by a loop.
#
# deploy-claim.sh honours a pre-existing .done with no .claim, so releases deployed before it existed are
# already skipped and arming this today fires on none of them.
#
# THE CLAIM IS TAKEN HERE, BEFORE THE SPAWN
#
# An action that claims its own work has claimed nothing if it dies before getting that far. So the loop
# claims, then spawns — which means the child must be TOLD the claim is already held. It is about to read
# prompts/deploy-commander.md, whose claim instruction is addressed to whoever reads it, and an obedient
# child that claims for itself gets exit 1 and correctly refuses to deploy. See PROMPT below.
set -u
PLATFORM_DIR="${PLATFORM_DIR:-$HOME/officer-dev/platform}"
# Empty in the sidecar's environment, where anything built from it resolves to /data/... and the record
# lands where nothing reads it. A watcher whose baseline is invisible to itself has none.
OFFICER_ROOT="${OFFICER_ROOT:-$HOME/officer-dev}"
DEPLOYS_DIR="${DEPLOYS_DIR:-$OFFICER_ROOT/data/deploys}"
POLL_SECONDS="${POLL_SECONDS:-120}"
export OFFICER_ROOT DEPLOYS_DIR
CLAIM_SH="$PLATFORM_DIR/scripts/deploy-claim.sh"
stamp() { date -Iseconds; }
if [ ! -x "$CLAIM_SH" ]; then
echo "$(stamp) deploy-watch: no claim script at $CLAIM_SH — refusing to arm without one" >&2
exit 2
fi
mkdir -p "$DEPLOYS_DIR" || exit 2
echo "$(stamp) deploy-watch: armed. polling $(cd "$PLATFORM_DIR" && git remote get-url origin) every ${POLL_SECONDS}s"
echo "$(stamp) deploy-watch: baseline is $DEPLOYS_DIR — a version with a claim is never fired again"
while true; do
# origin is the LOCAL gitea over the tailnet. The public hostname arrives through a proxy where every
# connection shares one source IP and SSH is refused for long stretches — which presents as an
# access-rights error and is not one. Eighty minutes of detection latency on 2026-08-21.
raw=$(cd "$PLATFORM_DIR" && timeout 30 git ls-remote --tags origin 'refs/tags/deploy/*' 2>&1)
rc=$?
if [ $rc -ne 0 ]; then
echo "$(stamp) tick: ls-remote failed (exit $rc) — ${raw%%$'\n'*}"
sleep "$POLL_SECONDS"
continue
fi
versions=$(printf '%s\n' "$raw" | grep -v '\^{}' | awk '{print $2}' | sed -E 's#.*refs/tags/deploy/##' | sort -u)
pending=""
for v in $versions; do
[ -n "$v" ] || continue
[ "$("$CLAIM_SH" state "$v" 2>/dev/null)" = unclaimed ] && pending="$pending $v"
done
# Every tick leaves a line, including the quiet ones: a fresh timestamp is the only thing that tells
# "nothing to deploy" apart from "the loop died".
echo "$(stamp) tick: $(printf '%s\n' "$versions" | grep -c .) tags,${pending:- none} pending"
for v in $pending; do
if ! "$CLAIM_SH" claim "$v"; then
# Lost a race with another watcher, or it was claimed between the state check and here. Either way
# it is not ours and never becomes ours.
continue
fi
echo "$(stamp) deploy/$v claimed — spawning deploy agent"
# The child reads the procedure, which tells ITS reader to claim first. It must not: we hold the claim
# on its behalf, and its own claim would return exit 1 and stop the deploy it was spawned to do.
PROMPT="Read prompts/deploy-commander.md in $PLATFORM_DIR and follow it exactly, for the release deploy/$v.
The claim for $v is ALREADY HELD on your behalf by the watcher that spawned you. Do NOT run
'scripts/deploy-claim.sh claim $v' — it will refuse, correctly, because the claim is taken, and stopping
there would abandon a deploy you were started to perform. Everything else in that file applies unchanged,
including running 'scripts/deploy-claim.sh done $v' once the rollout has succeeded.
Roll it out across every host the plan names, skip the host you are running on, and write the report
exactly as the procedure specifies."
raw_log="$DEPLOYS_DIR/$v.child.jsonl"
# Nothing links a spawned agent to its parent and a child transcript does not last, so the stream is
# kept here as it arrives. stderr never enters the pipe: the CLI writes warnings there and one landing
# in the stream corrupts the first jq parse.
{
cd "$PLATFORM_DIR" && claude -p "$PROMPT" \
--dangerously-skip-permissions \
--output-format=stream-json \
--verbose
} 2> >(while IFS= read -r l; do echo " ! $l"; done) \
| tee -a "$raw_log" \
| jq -r --unbuffered '
if .type=="system" and .subtype=="init" then
" · child session " + .session_id
elif .type=="assistant" then
(.message.content[]? |
if .type=="tool_use" then
" ⚙ " + .name + ": " + ((.input.description // .input.command // .input.pattern // (.input | tostring)))
elif .type=="text" then
" » " + .text
else empty end)
elif .type=="user" then
(.message.content[]? | select(.type=="tool_result") |
" " + (if (.content|type)=="string" then .content else (.content // [] | map(.text? // "") | join(" ")) end))
elif .type=="result" then
" = deploy agent " + .subtype + ", " + (.num_turns|tostring) + " turns, " + (.total_cost_usd|tostring) + " usd"
else empty end
' 2>/dev/null
if [ -f "$DEPLOYS_DIR/$v.done" ]; then
echo "$(stamp) deploy/$v SUCCEEDED — recorded, transcript at $raw_log"
else
# The claim stays held. This is the loop's most important line and the one that used to say
# "will retry next poll".
echo "$(stamp) deploy/$v FAILED — no .done written. NOT retrying; the claim is held."
echo "$(stamp) transcript: $raw_log"
echo "$(stamp) to allow another attempt, deliberately: rm -rf $DEPLOYS_DIR/$v.claim"
fi
done
sleep "$POLL_SECONDS"
done
+43
View File
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { Glob } from 'bun';
/*
Which settings sections actually exist, read from the screens that declare them.
Separated from `docs-check.ts` because it is the only check that parses source rather than importing it —
the sections are JSX-adjacent object literals inside components, so importing them would drag React and a
browser environment into a CLI. A regex over `BASE_PATH` and `key:` is the honest tool for that, and it is
narrow enough to be obvious when it breaks.
*/
const ROOT = resolve(import.meta.dir, '..');
const SETTINGS_DIR = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Settings');
/** `/settings/profile` + `change-password` → `/settings/profile/change-password`. */
export async function declaredSettingsPaths(): Promise<Set<string>> {
const paths = new Set<string>();
const glob = new Glob('**/*.tsx');
for await (const file of glob.scan({ cwd: SETTINGS_DIR, absolute: true })) {
const body = readFileSync(file, 'utf-8');
const base = /BASE_PATH\s*=\s*'([^']+)'/.exec(body)?.[1];
if (!base) continue;
paths.add(base);
for (const m of body.matchAll(/key:\s*'([^']+)'/g)) paths.add(`${base}/${m[1]}`);
}
return paths;
}
/**
* Settings routes Spotlight offers, and which of them nothing declares.
*
* Parsed rather than imported for the same reason: `useSpotlightItems` is a hook and pulls in the dock,
* lucide and React.
*/
export async function SPOTLIGHT_SETTINGS_PATHS(): Promise<{ offered: string[]; missing: string[] }> {
const file = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts');
const body = readFileSync(file, 'utf-8');
const offered = [...body.matchAll(/to:\s*'(\/settings\/[^']+)'/g)].map((m) => m[1]!);
const declared = await declaredSettingsPaths();
return { offered, missing: offered.filter((p) => !declared.has(p)) };
}
+169
View File
@@ -0,0 +1,169 @@
/*
Does the documentation still match the code?
── Why a script and not a habit ──
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
number is exactly the thing nobody re-derives while editing a sentence around it.
Real examples this was written from, both live at the time:
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
── The rule for adding a check ──
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
the same edit that misses the doc.
*/
import { readFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
type Finding = { file: string; claim: string; documented: string; actual: string };
type CheckResult = { findings: Finding[]; skipped?: string };
const ROOT = resolve(import.meta.dir, '..');
const WORKSPACE = resolve(ROOT, '..');
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
const WORDS: Record<string, number> = {
ten: 10,
eleven: 11,
twelve: 12,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20,
'twenty-one': 21,
'twenty-two': 22,
'twenty-three': 23,
'twenty-four': 24,
'twenty-five': 25,
};
const asNumber = (raw: string): number | null => {
const digits = Number(raw);
if (Number.isFinite(digits)) return digits;
return WORDS[raw.toLowerCase()] ?? null;
};
/**
* A documented count, found by a pattern with one capture group, compared to the live value.
*
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
*/
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
const body = read(file);
if (!body) return { findings: [], skipped: `${file} not found` };
const findings: Finding[] = [];
for (const match of body.matchAll(pattern)) {
const stated = asNumber(match[1] ?? '');
if (stated === null || stated === actual) continue;
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
}
return { findings };
}
async function main(): Promise<void> {
const all: Finding[] = [];
const skipped: string[] = [];
const record = (r: CheckResult) => {
all.push(...r.findings);
if (r.skipped) skipped.push(r.skipped);
};
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
record(
checkCount(
join(ROOT, 'CLAUDE.md'),
/([0-9]+|[a-z-]+) entries as of/gi,
PERMISSIONS.length,
'permission registry entries',
),
);
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
// admission that it needs a check rather than a caveat.
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
if (pm2.exitCode === 0) {
try {
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
record(
checkCount(
join(WORKSPACE, 'CLAUDE.md'),
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
officer,
'officer PM2 entries',
),
);
} catch {
skipped.push('pm2 jlist did not parse');
}
} else {
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
skipped.push('pm2 unavailable — PM2 entry count unchecked');
}
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
const declared = await SPOTLIGHT_SETTINGS_PATHS();
for (const bad of declared.missing) {
all.push({
file: 'Spotlight/useSpotlightItems.ts',
claim: 'settings route offered by Spotlight',
documented: bad,
actual: 'no screen declares that section',
});
}
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
// an agent quotes it to a user with no way to tell it is out of date.
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
const expected = await renderPlatformSkill();
const onDisk = read(SKILL_PATH);
if (onDisk === null) {
all.push({
file: 'capabilities/skills/platform/SKILL.md',
claim: 'generated platform skill',
documented: 'missing',
actual: 'run `bun run skills:platform`',
});
} else if (onDisk !== expected) {
all.push({
file: 'capabilities/skills/platform/SKILL.md',
claim: 'generated platform skill is stale',
documented: `${onDisk.split('\n').length} lines on disk`,
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
});
}
for (const note of skipped) console.log(` skipped: ${note}`);
if (all.length === 0) {
console.log('docs:check — every checkable claim matches the code');
return;
}
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
for (const f of all) {
console.log(` ${f.file}`);
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
}
process.exit(1);
}
await main();
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bun
/**
* `bun run doctor` — check the invariants that provisioning cannot check for itself.
*
* ── Why ──
*
* Every step in `provisionOsAccount` is deliberately non-fatal, so "nothing reported an error" is much
* weaker than it sounds. The 2026-08-19 case: a fresh server, an Admin granted files, chat and terminal,
* every named ACL entry correct, provisioning clean — and the account reached nothing, because a later
* `chmod 700` had recomputed the ACL mask to `---`. It was found by a person, three days and one
* confusing UI message later.
*
* This is the same posture as `assertPermissionTotality`: state the invariant, verify it, name the fix.
* The difference is that this reports rather than refusing to boot — a broken member must not stop the
* platform serving everyone else.
*
* Read-only. It changes nothing, so it is safe on a production box.
*
* Exit 1 if anything is wrong, so it can gate the end of a setup run.
*/
import { getUsers, getAllRoleGrants, OWNER_USER_ID } from 'officerdb';
import { checkOsAccount } from '../src/servers/os-user-verify';
import { PERMISSIONS } from '../src/servers/permissions/registry';
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
let problems = 0;
const fail = (what: string, fix: string) => {
problems++;
console.log(`${red('✗')} ${what}`);
console.log(` ${dim(fix)}`);
};
const pass = (what: string) => console.log(`${green('✓')} ${what}`);
console.log('\n── Runtime ──\n');
/**
* bun is deliberately held at a series — `BUN_SERIES` in
* scripts/setup/machine-setup/lib/dev.sh — while 1.4 is watched for a while.
*
* The setup script can pin the INSTALL and nothing more. `bun upgrade` takes no version
* argument (it offers latest-stable or --canary and nothing else), so it always jumps to the
* newest release and walks a pinned machine off the pin without saying so. This is the only
* thing that notices, which is why a version check sits in a script otherwise about accounts.
*
* The wanted series is READ from `engines.bun` rather than repeated here, because a second
* hardcoded copy is a second thing to drift. bun does not enforce that field — verified: an
* install declaring `engines.bun: "1.9.x"` ran clean under 1.3.14, no warning — so the field is
* the declaration and this is the enforcement.
*/
const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as {
engines?: { bun?: string };
};
const wantSeries = (pkg.engines?.bun ?? '').replace(/\.x$/, '');
const nowSeries = Bun.version.split('.').slice(0, 2).join('.');
if (!wantSeries) {
fail(
'package.json declares no engines.bun',
'The setup scripts pin a bun series and nothing else records which one. Add it.',
);
} else if (nowSeries === wantSeries) {
pass(`bun ${Bun.version} ${dim(`(inside the pinned ${wantSeries} series)`)}`);
} else {
fail(
`bun ${Bun.version} is outside the pinned ${wantSeries} series`,
`Usually a 'bun upgrade', which always takes the newest release. Pin it back with ` +
`'curl -fsSL https://bun.sh/install | bash -s bun-v<newest ${wantSeries}.x>', or raise ` +
`engines.bun and BUN_SERIES together if ${nowSeries} is now wanted.`,
);
}
console.log('\n── Accounts ──\n');
const users = await getUsers();
for (const user of users) {
if (user.id === OWNER_USER_ID) {
// The owner runs AS the service user, in the service user's own home. There is no Linux account to
// check and no ACL to get wrong — `osUser: null` is correct here, not a finding.
pass(`${user.email} ${dim('(owner — runs as the service account)')}`);
continue;
}
const check = await checkOsAccount(user.email, user.osUser ?? null);
if (check.ok) {
pass(`${user.email} ${dim(`(${user.role}, os_user=${check.osUser}, home readable)`)}`);
} else {
fail(`${user.email} (${user.role}) — ${check.problem}`, `home: ${check.home}`);
}
}
console.log('\n── Grants that cannot take effect ──\n');
// A `confined` grant is silently dropped for an account with no Linux user. That is correct behaviour and
// completely invisible: the grant is in the table, the UI shows it ticked, and it resolves to nothing.
const confinedKeys = new Set(PERMISSIONS.filter((p) => p.kind === 'confined').map((p) => p.key));
const grants = await getAllRoleGrants();
const unconfined = users.filter((u) => u.id !== OWNER_USER_ID && !u.osUser);
if (!unconfined.length) {
pass('every non-owner account has a Linux user');
} else {
for (const user of unconfined) {
const dropped = grants
.filter((g) => g.role === user.role && confinedKeys.has(g.permission))
.map((g) => g.permission);
if (!dropped.length) continue;
fail(
`${user.email} (${user.role}) holds ${dropped.join(', ')} — all dropped, because the account has no Linux user`,
'Fix: Settings → User management → provision, or POST /api/users/<id>/provision-linux',
);
}
}
console.log('');
if (problems === 0) {
console.log(green('No problems found.\n'));
process.exit(0);
}
console.log(red(`${problems} problem${problems === 1 ? '' : 's'} found.\n`));
process.exit(1);
+14
View File
@@ -0,0 +1,14 @@
// `bun run fix:env` — give every variable in `.env` the `OFFICERDEV_` prefix.
//
// A thin front for `servers/env-prefix.ts`, which is where the rules live. Deliberately that way round:
// the same conversion runs on every restart from the server settings screen, and a router reaching into
// `scripts/` to do it would make a maintenance script part of the request path.
import { join } from 'node:path';
import { fixEnvFile } from '../src/servers/env-prefix';
const path = process.argv[2] ?? join(process.cwd(), '.env');
const { changed, alreadyPrefixed } = fixEnvFile(path);
if (changed.length) console.log(`[fix:env] prefixed ${changed.length}: ${changed.join(', ')}`);
else console.log(`[fix:env] nothing to do (${alreadyPrefixed} already prefixed)`);
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bun
/**
* Rebuild ecosystem.config.cjs from scratch: the core processes, plus one PM2 entry for every sidecar
* this install actually has enabled — marketplace plugins that ship one, and app-store services.
*
* ── Where each part of the answer comes from, and why ──
*
* CORE_PROCESSES is sourced from `scripts/setup/officer-setup/lib/services.sh` rather than duplicated
* here — that file is the one officer-setup itself writes from, and a second hardcoded list is a second
* thing to keep in sync with it. This shells out to bash to read the array directly, so there is exactly
* one list.
*
* Plugin sidecars come from crossing two facts that live in different places on purpose: `discoverPlugins`
* says what is on DISK (a plugin with a `sidecar/` directory), and `listPluginInstalls` says what the
* owner has actually installed and enabled in Postgres. Neither alone is the answer — a plugin can be on
* disk but never installed, or installed and then disabled without being removed.
*
* App-store sidecars are the same cross, against `sidecarInstalls`/`CATALOGUE` instead: every catalogue
* entry names a `process`, and `src/servers/sidecar/<id>/index.ts` is where it lives — a convention, not
* a lookup, the same way `officer-claude-code`'s script path is not stored anywhere but its own directory
* name.
*
* ── Why this exists alongside `servers/plugins/ecosystem.ts` ──
*
* That file does the incremental add/remove at install/uninstall time — a live, running edit. This is
* the other direction: rebuild the whole file from what the database says exists, for a fresh clone,
* drift, or restoring from a backup. Neither replaces the other.
*/
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { CATALOGUE } from '../src/servers/app-store/catalogue';
import { PLATFORM_DIR } from '../src/servers/data-path';
import { discoverPlugins } from '../src/servers/plugins/discover';
import { pluginPm2Entry, type Pm2App } from '../src/servers/plugins/ecosystem';
import { listPluginInstalls, listSidecarInstalls } from 'officerdb';
const done = (message: string): never => {
console.log(`[gen:ecosystem] ${message}`);
process.exit(0);
};
const fail = (message: string): never => {
console.error(`[gen:ecosystem] ${message}`);
process.exit(1);
};
/** The single source of truth for the core list is the bash array itself, not a copy of it. */
function coreProcesses(): Pm2App[] {
const servicesLib = join(PLATFORM_DIR, 'scripts/setup/officer-setup/lib/services.sh');
const result = spawnSync('bash', ['-c', `source '${servicesLib}' && printf '%s\\n' "\${CORE_PROCESSES[@]}"`], {
encoding: 'utf-8',
});
if (result.status !== 0) {
fail(`could not read CORE_PROCESSES from ${servicesLib}: ${result.stderr || `exit ${result.status}`}`);
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, script, args] = line.split('|');
if (!name || !script) return fail(`unreadable CORE_PROCESSES entry: "${line}"`);
return { name, script, args, cwd: PLATFORM_DIR, watch: false };
});
}
/** Installed AND enabled AND actually ships a sidecar — all three, or it gets no entry. */
async function pluginEntries(): Promise<Pm2App[]> {
const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
const enabled = new Set(installs.filter((i) => i.enabled).map((i) => i.appName));
return plugins.filter((p) => enabled.has(p.appName)).flatMap((p) => pluginPm2Entry(p) ?? []);
}
/** Installed AND enabled. Unlike a plugin, every catalogue entry has a `process` — none are filtered out. */
async function appStoreEntries(): Promise<Pm2App[]> {
const installs = await listSidecarInstalls();
const enabled = new Set(installs.filter((i) => i.enabled).map((i) => i.sidecarId));
return CATALOGUE.filter((entry) => enabled.has(entry.id)).map((entry) => ({
name: entry.process,
script: 'bun',
args: `run src/servers/sidecar/${entry.id}/index.ts`,
cwd: PLATFORM_DIR,
watch: false,
}));
}
const [core, plugins, appStore] = await Promise.all([
Promise.resolve(coreProcesses()),
pluginEntries(),
appStoreEntries(),
]);
const apps = [...core, ...plugins, ...appStore];
// The three sources should never collide by construction (core names are fixed, plugin names are
// `officer-<appName>`, app-store names come from the catalogue) — but a later entry silently shadowing
// an earlier one in the written file is a worse failure than refusing outright.
const seen = new Set<string>();
for (const app of apps) {
if (seen.has(app.name)) fail(`duplicate pm2 process name: ${app.name} — refusing to write a shadowed entry`);
seen.add(app.name);
}
const HEADER = `// Generated by "bun gen:ecosystem" (scripts/gen-ecosystem.ts). Not in git, and not meant to
// be — it describes THIS install. Re-run it any time the database's idea of what is installed and
// enabled has moved on from what this file says: a fresh clone, drift, restoring from a backup.
//
// \`cwd\` is pinned on every app for two reasons. Bun auto-loads .env from the working directory (and the
// pty sidecar does \`import 'dotenv/config'\`), so without it a process started from anywhere else comes
// up with no POSTGRES_URL. And src/servers/data-path.ts derives the install root as the PARENT of the
// working directory, so a wrong cwd does not fail — it relocates data/, capabilities/ and dockers/
// somewhere else entirely. \`assertInstallLayout\` refuses to boot when that happens.
`;
const lines = apps.map(
(app) =>
` { name: '${app.name}', script: '${app.script}'` +
(app.args ? `, args: '${app.args}'` : '') +
`, cwd: '${app.cwd}', watch: ${app.watch} },`,
);
const dest = join(PLATFORM_DIR, 'ecosystem.config.cjs');
await Bun.write(dest, `${HEADER}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`);
done(`wrote ${dest} — ${core.length} core, ${plugins.length} plugin, ${appStore.length} app-store`);
+11 -5
View File
@@ -27,17 +27,23 @@ const output = join(root, 'src/apps/officer-web/index.gen.html');
const argUrl = process.argv[2]?.trim(); const argUrl = process.argv[2]?.trim();
const envPath = join(root, '.env'); const envPath = join(root, '.env');
if (!argUrl && !process.env.PUBLIC_URL && existsSync(envPath)) { // Prefixed first, bare as the fallback — the same rule every other consumer follows. This script reads
// the file itself rather than relying on the environment, because it is run by hand as often as by the
// setup, and `bun run gen:index` in a bare shell has no `.env` loaded.
const envValue = (): string | undefined => process.env.OFFICERDEV_PUBLIC_URL ?? process.env.PUBLIC_URL;
if (!argUrl && !envValue() && existsSync(envPath)) {
for (const line of (await Bun.file(envPath).text()).split('\n')) { for (const line of (await Bun.file(envPath).text()).split('\n')) {
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/); // Either spelling, so a file that has been through `bun run fix:env` and one that has not both work.
if (match) process.env.PUBLIC_URL = match[1]!.trim().replace(/^["']|["']$/g, ''); const match = line.match(/^\s*(?:OFFICERDEV_)?PUBLIC_URL\s*=\s*(.*)$/);
if (match) process.env.OFFICERDEV_PUBLIC_URL = match[1]!.trim().replace(/^["']|["']$/g, '');
} }
} }
const publicUrl = (argUrl || process.env.PUBLIC_URL || '').replace(/\/+$/, ''); const publicUrl = (argUrl || envValue() || '').replace(/\/+$/, '');
if (!publicUrl) { if (!publicUrl) {
console.error('[gen-index] no public URL. Pass one — `bun gen:index https://officer.example.com` —'); console.error('[gen-index] no public URL. Pass one — `bun gen:index https://officer.example.com` —');
console.error('[gen-index] or set PUBLIC_URL in .env.'); console.error('[gen-index] or set OFFICERDEV_PUBLIC_URL in .env.');
process.exit(1); process.exit(1);
} }
+167
View File
@@ -0,0 +1,167 @@
/*
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
── Why generated ──
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
and the prose is limited to what no registry can express.
── The split, and where the line is ──
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
keyboard shortcuts
PROSE what the platform IS, what a permission KIND means for a user, where to look next
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
differs, so a stale skill is a failed check rather than a confident wrong answer.
*/
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
const KIND_MEANING: Record<string, string> = {
core: 'every account has it and it cannot be denied — the caller acting on themselves',
app: 'grantable per role, at read or write',
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
admin: 'the server owner only',
};
export async function renderPlatformSkill(): Promise<string> {
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
const enCatalog = (await import('../src/apps/officer-web/locales/en-GB.json')).default as Record<string, unknown>;
/** Resolve a dot-path against the English catalog. This doc is for agents and stays in one language. */
const enString = (key: string): string => {
const value = key.split('.').reduce<unknown>((node, part) => {
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
return undefined;
}, enCatalog);
if (typeof value !== 'string') throw new Error(`gen-platform-skill: no English string for "${key}"`);
return value;
};
const byKind = new Map<string, typeof PERMISSIONS>();
for (const p of PERMISSIONS) {
const list = byKind.get(p.kind) ?? [];
list.push(p);
byKind.set(p.kind, list as typeof PERMISSIONS);
}
const lines: string[] = [];
const out = (s = '') => lines.push(s);
out('---');
out('name: Officer platform');
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
out('description: >-');
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
out('version: 1');
out('---');
out();
out('<!--');
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
out(' differs from what the generator produces, and an edit here is lost on the next run.');
out();
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
out('-->');
out();
out('# What Officer is');
out();
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
out('user, and **absence denies** — there is no row meaning "no".');
out();
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
out('no, and *not shareable at all*.');
out();
out('# Permissions');
out();
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
out('diligence.');
out();
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
const entries = byKind.get(kind) ?? [];
if (!entries.length) continue;
out(`## \`${kind}\` — ${meaning}`);
out();
for (const p of entries) {
const bits: string[] = [];
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
if (bits.length) out(` - ${bits.join(' · ')}`);
if (p.selfService?.length) {
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
}
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
if (p.readOnlyWrites?.length) {
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
}
}
out();
}
out('# Keyboard shortcuts');
out();
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
out('list rather than remembering one.');
out();
// The registry holds catalog keys since the shell was localized; this doc is agent-facing and stays
// English, so it resolves against en-GB rather than the reader's locale.
for (const section of SHORTCUT_SECTIONS) {
out(`## ${enString(section.titleKey)}`);
if (section.noteKey) {
out();
out(enString(section.noteKey));
}
out();
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${enString(s.descriptionKey)}`);
out();
}
out('# What is NOT in here');
out();
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
out(' commander is, how one is started, and how to help somebody shape one.');
out();
out('# When you are about to guess');
out();
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
out('certain as one quoting a true one.');
return `${lines.join('\n')}\n`;
}
if (import.meta.main) {
const body = await renderPlatformSkill();
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
if (existing === body) {
console.log('skills:platform — already current');
} else {
writeFileSync(SKILL_PATH, body);
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
}
}
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bun
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { loadPalette } from './themes/palette';
import { mapPalette } from './themes/map';
import { TEXT, UI, contrast } from './themes/contrast';
// Generate Officer themes from Omarchy's, and refuse to write an unreadable one.
//
// bun scripts/generate-themes.ts <path-to-omarchy-checkout> [--check]
//
// `--check` writes nothing and reports what would change, which is what a test runs.
//
// ── Why generated rather than authored ──
//
// Twenty-two themes hand-written against an 84-token contract will disagree, and the previous attempt
// proved it: eleven themes, 34 tokens each, every surface missing. One mapping means one place to be
// wrong and one place to fix — and a palette that Omarchy updates is a re-run rather than a rewrite.
//
// The Duck and Plain themes are NOT generated. They are Officer's own, authored, and this must never
// overwrite them.
const OMARCHY = process.argv[2];
const CHECK = process.argv.includes('--check');
const THEME_DIR = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'css', 'themes');
if (!OMARCHY || !existsSync(join(OMARCHY, 'themes'))) {
console.error('usage: bun scripts/generate-themes.ts <path-to-omarchy-checkout> [--check]');
process.exit(1);
}
/** Officer's own. Generated themes may never take these names. */
const AUTHORED = new Set(['duck-light', 'duck-dark', 'plain']);
const title = (id: string): string =>
id
.split('-')
.map((w) => (w.length <= 2 ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
.join(' ');
/**
* The pairs that must be readable, checked after mapping rather than trusted.
*
* `ensureContrast` already guarantees most of these — this is the belt to that braces. A mapping change
* that quietly breaks one shows up here as a failure instead of as somebody squinting at a screenshot.
*/
const PAIRS: [string, string, number][] = [
['--foreground', '--background', TEXT],
['--card-foreground', '--card', TEXT],
['--popover-foreground', '--popover', TEXT],
['--muted-foreground', '--muted', TEXT],
['--accent-foreground', '--accent', TEXT],
['--primary-foreground', '--primary', TEXT],
['--secondary-foreground', '--secondary', TEXT],
['--cta-foreground', '--cta', TEXT],
['--success-foreground', '--success', TEXT],
['--warning-foreground', '--warning', TEXT],
['--destructive-foreground', '--destructive', TEXT],
['--info-foreground', '--info', TEXT],
['--busy-foreground', '--busy', TEXT],
['--busy-ink', '--background', TEXT],
// Not `--border` at UI: a panel edge is decoration and 3:1 would ring everything in a hard line. What
// must be visible is a form field's boundary and the focus ring — the two that carry information.
['--input', '--background', 1.35],
['--ring', '--background', UI],
];
let failures = 0;
let written = 0;
const generated: { id: string; name: string; dark: boolean }[] = [];
for (const id of readdirSync(join(OMARCHY, 'themes')).sort()) {
const toml = join(OMARCHY, 'themes', id, 'colors.toml');
if (!existsSync(toml)) continue;
if (AUTHORED.has(id)) {
console.log(` ${id}: SKIPPED — Officer's own theme, never generated`);
continue;
}
const palette = loadPalette(toml);
const tokens = mapPalette(palette);
for (const [fgKey, bgKey, min] of PAIRS) {
const fg = tokens[fgKey]!;
const bg = tokens[bgKey]!;
// Only flat hex pairs are checkable — a translucent surface depends on what is behind it.
if (!fg.startsWith('#') || !bg.startsWith('#')) continue;
const ratio = contrast(fg, bg);
if (ratio < min) {
console.error(` ${id}: ${fgKey} on ${bgKey} is ${ratio.toFixed(2)}:1, needs ${min}:1`);
failures++;
}
}
const body = Object.entries(tokens)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n');
const css = `/*
${title(id)} — generated from Omarchy's palette by scripts/generate-themes.ts.
DO NOT EDIT BY HAND. Change the mapping in scripts/themes/map.ts and re-run, so every theme moves
together — the previous port drifted precisely because each file was edited on its own.
Source: basecamp/omarchy themes/${id}/colors.toml (mode: ${palette.mode})
*/
[data-theme='${id}'] {
${body}
}
`;
const path = join(THEME_DIR, `${id}.css`);
const before = existsSync(path) ? readFileSync(path, 'utf8') : '';
if (before !== css) {
if (!CHECK) writeFileSync(path, css);
written++;
}
generated.push({ id, name: title(id), dark: palette.mode === 'dark' });
}
console.log(`\n ${generated.length} themes, ${written} ${CHECK ? 'would change' : 'written'}, ${failures} contrast failures`);
if (!CHECK) {
// The registry and the stylesheet index, rewritten from what was generated so a theme cannot exist as
// a file nobody imports — which is a theme that silently does not work.
const imports = generated.map((t) => `@import './themes/${t.id}.css';`).join('\n');
const indexPath = join(THEME_DIR, '..', 'index.css');
const index = readFileSync(indexPath, 'utf8');
const marker = '/* Palette themes';
const head = index.slice(0, index.indexOf(marker));
writeFileSync(
indexPath,
`${head}/* Palette themes — generated from Omarchy by scripts/generate-themes.ts. Do not edit this block. */\n${imports}\n\n@import './chrome.css';\n`,
);
console.log(` index.css: ${generated.length} imports`);
// The REGISTRY, from the same list. A theme file nothing imports does not work; a theme imported but
// absent from here does not appear in the picker. Both were true of the previous port — catppuccin-mocha
// outlived its stylesheet — so both halves are written from one source rather than maintained by hand.
//
// Display names get a pass for the ones a slug cannot spell.
const NICER: Record<string, string> = {
'rose-pine': 'Rosé Pine',
'retro-82': 'Retro 82',
'tokyo-night': 'Tokyo Night',
'catppuccin': 'Catppuccin Mocha',
'catppuccin-latte': 'Catppuccin Latte',
};
const FAMILY: Record<string, string> = { catppuccin: 'Catppuccin', 'catppuccin-latte': 'Catppuccin' };
const rows = generated
.map((t) => {
const name = NICER[t.id] ?? t.name;
const family = FAMILY[t.id] ?? name;
return ` { id: '${t.id}', name: '${name}', family: '${family}', applyDarkClass: ${t.dark} },`;
})
.join('\n');
const registryPath = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'src', 'themes.ts');
const registry = readFileSync(registryPath, 'utf8');
const START = ' /* GENERATED THEMES — start */';
const END = ' /* GENERATED THEMES — end */';
if (!registry.includes(START)) {
console.error(' themes.ts: missing the GENERATED THEMES markers — not touching it');
process.exit(1);
}
const registryHead = registry.slice(0, registry.indexOf(START) + START.length);
const registryTail = registry.slice(registry.indexOf(END));
writeFileSync(registryPath, `${registryHead}\n${rows}\n${registryTail}`);
console.log(` themes.ts: ${generated.length} entries`);
}
process.exit(failures > 0 ? 1 : 0);
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env bun
/**
* Counts candidate hardcoded user-facing strings in the web UI.
*
* Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area
* reaches zero it becomes the guard that stops the debt coming back — see ZEROED below.
*
* It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries
* inside JSX, strings passed down as props, or anything a plugin renders. A falling number means
* progress; a zero means "nothing this scanner can see", not "nothing left".
*
* bun scripts/i18n/scan-hardcoded.ts # summary by area
* bun scripts/i18n/scan-hardcoded.ts --files # worst files first
* bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves
* bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const AREAS = [
{ label: 'officer-web (shell)', root: 'src/apps/officer-web' },
{ label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' },
{ label: 'shared components', root: 'src/workspaces/components' },
] as const;
/**
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
* zero — enabling every area on day one would just be red for weeks and get ignored.
*
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
* that must NEVER be translated; they are reported but excluded from the check, which is why the
* neverTranslate split below has to exist for this entry to be possible at all.
*
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
* `stripUntranslated` skips for the same reason.
*/
const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
/**
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
*
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
*/
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
// >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends
{
kind: 'attributes',
rx: new RegExp(
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
'g',
),
},
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
{
kind: 'object labels',
rx: new RegExp(
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
'g',
),
},
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
];
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
function isHuman(value: string | undefined): value is string {
if (!value) return false;
const s = value.trim();
if (s.length < 3) return false;
if (!/[A-Za-z]{3}/.test(s)) return false;
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
// Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
if (/^[A-Z0-9_.-]+$/.test(s)) return false;
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
// followed by a credential is never something a person reads.
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
// A single token carrying digits AND punctuation is a name, not a sentence — the OCR section's
// placeholder is the example model file `Qwen2.5-VL-7B-Instruct-q4_k_m.gguf`. Prose has spaces; the
// whitespace test is what keeps this from eating real one-word labels, which never look like this.
if (!/\s/.test(s) && /\d/.test(s) && /[-_.]/.test(s)) return false;
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
// `description: 'group-[.toast]:text-muted-foreground'`.
//
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
const tokens = s.split(/\s+/);
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
return true;
}
/**
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
*
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
*/
const stripComments = (source: string): string =>
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
/**
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
* thing it documents.
*/
const stripCodeElements = (source: string): string =>
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
/**
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
* another product's UI, which docs/localization.md classifies as an identifier.
*
* Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
* doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
* carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
* it and say why.
*/
const stripUntranslated = (source: string): string =>
source.replace(/<Untranslated\b[^>]*>[\s\S]*?<\/Untranslated>/g, '<Untranslated/>');
function* walk(dir: string): Generator<string> {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const path = join(dir, entry);
if (SKIP.test(path)) continue;
let isDir = false;
try {
isDir = statSync(path).isDirectory();
} catch {
continue;
}
if (isDir) yield* walk(path);
else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path;
}
}
type Hit = { kind: string; value: string; file: string };
function scan(root: string): { files: number; hits: Hit[] } {
const hits: Hit[] = [];
let files = 0;
for (const file of walk(root)) {
files++;
let source: string;
try {
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
} catch {
continue;
}
for (const { kind, rx } of PATTERNS) {
rx.lastIndex = 0;
for (const match of source.matchAll(rx)) {
const value = match.slice(1).find((group) => group !== undefined);
if (isHuman(value)) hits.push({ kind, value: value.trim(), file });
}
}
}
return { files, hits };
}
const args = new Set(process.argv.slice(2));
const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined;
const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n);
console.log(
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
);
for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
}
const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
console.log(
`${'TOTAL'.padEnd(22)} ${pad(
results.reduce((n, r) => n + r.files, 0),
5,
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
);
console.log();
console.log(`occurrences ${all.length}`);
console.log(` convertible ${convertible(all).length}`);
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
console.log(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`);
if (args.has('--files')) {
console.log('\nworst files:');
const perFile = new Map<string, number>();
for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1);
for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
console.log(` ${pad(n, 4)} ${file}`);
}
}
if (listPrefix) {
console.log(`\nstrings in files matching "${listPrefix}":`);
for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`);
}
if (args.has('--check')) {
const regressed = results
.filter((r) => ZEROED.includes(r.label))
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
.filter((r) => r.hits.length > 0);
if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:');
for (const r of regressed) {
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
}
console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1);
}
console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`);
}
+17 -8
View File
@@ -123,9 +123,7 @@ async function syncOne(dir: string): Promise<Sync | null> {
}); });
console.log( console.log(
`[marketplace-sync] ${appName} ← ${repoUrl}${readme ? '' : ' (no README)'}${ `[marketplace-sync] ${appName} ← ${repoUrl}${readme ? '' : ' (no README)'}${has.web ? '' : ' (no web/)'}`,
has.web ? '' : ' (no web/)'
}`,
); );
return { appName, developer, repoUrl }; return { appName, developer, repoUrl };
} }
@@ -133,16 +131,27 @@ async function syncOne(dir: string): Promise<Sync | null> {
/** /**
* A raw-file URL for a Gitea repository. * A raw-file URL for a Gitea repository.
* *
* `[open]` Gitea-shaped and branch-`main`-shaped. A GitHub-hosted plugin needs the other form, and a * `HEAD`, not a branch name. This said `branch/main` and the comment here warned that "a repo whose
* repo whose default branch is `master` gets a 404. Fine while every plugin is on our own Gitea; the * default branch is `master` gets a 404" — which then happened: four plugin repositories were created
* moment one is not, this needs the forge to be part of the registry rather than inferred. * on `master` and every one of them served a broken image in the marketplace on any server that reads
* the remote catalogue. A warning in a comment is not a guard.
*
* Gitea resolves `HEAD` to whatever the default branch is, so the question stops being asked. Verified
* against both spellings.
*
* `[open]` Still Gitea-shaped. A GitHub-hosted plugin needs the other form, and that wants the forge to
* be part of the registry rather than inferred from the URL.
*/ */
function rawUrl(repoUrl: string, path: string): string { function rawUrl(repoUrl: string, path: string): string {
return `${repoUrl.replace(/\.git$/, '')}/raw/branch/main/${path}`; return `${repoUrl.replace(/\.git$/, '')}/raw/HEAD/${path}`;
} }
const dirs = existsSync(PLUGINS_DIR) const dirs = existsSync(PLUGINS_DIR)
? [...new Bun.Glob('*/').scanSync({ cwd: PLUGINS_DIR, onlyFiles: false })].map((d) => join(PLUGINS_DIR, d)) ? [...new Bun.Glob('*/').scanSync({ cwd: PLUGINS_DIR, onlyFiles: false })]
// Not a plugin: the shared install target for plugin npm packages, which lives here because bun
// resolves node_modules up the tree. `discoverPlugins` skips it for the same reason.
.filter((d) => d.replace(/\/$/, '') !== 'node_modules')
.map((d) => join(PLUGINS_DIR, d))
: []; : [];
if (dirs.length === 0) { if (dirs.length === 0) {
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bun
/**
* Fire one notification at a registered device, to prove the pipe.
*
* bun run notify:test # officer/core:test to user 1
* bun run notify:test --user 2 # someone else
* bun run notify:test --code officer/core:job --id 412
*
* Talks to the sidecar over loopback, which is how every other producer reaches it — so this exercises
* the real path rather than a special one. It reports what the channels actually did, because the thing
* that wastes a day here is that a send with nothing configured returns success and delivers nothing.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../src/servers/data-path';
const arg = (name: string): string | undefined => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? undefined : process.argv[i + 1];
};
const code = arg('code') ?? 'officer/core:test';
const userId = Number(arg('user') ?? 1);
const id = arg('id');
const n = arg('n');
const addressFile = join(DATA_PATH, 'notify-server.url');
let base: string;
try {
base = readFileSync(addressFile, 'utf8').trim();
} catch {
console.error(`[notify:test] no address at ${addressFile}`);
console.error(' officer-notify is not running — `pm2 start ecosystem.config.cjs --only officer-notify`');
process.exit(1);
}
// The file is only a hint: a crash leaves it behind, so prove the listener is there before believing it.
let health: { ok: boolean; channels: string[] };
try {
const res = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(5000) });
health = (await res.json()) as typeof health;
} catch {
console.error(`[notify:test] ${base} is not answering — stale address file, or the sidecar is down`);
process.exit(1);
}
console.log(`[notify:test] sidecar ${base}`);
console.log(`[notify:test] channels configured: ${health.channels.length ? health.channels.join(', ') : 'NONE'}`);
if (health.channels.length === 0) {
// Said before sending rather than after, because the send below WILL return ok and deliver nothing.
console.error('');
console.error('[notify:test] nothing is configured, so this cannot reach a device.');
console.error(' APNs needs apns-p8, apns-key-id and apns-team-id:');
console.error(' cat AuthKey_XXXXXXXXXX.p8 | bun run secret:set apns-p8');
console.error(' echo -n XXXXXXXXXX | bun run secret:set apns-key-id');
console.error(' echo -n RGHC4NJC77 | bun run secret:set apns-team-id');
console.error(' then: pm2 restart officer-notify');
process.exit(1);
}
const body = {
code,
userId,
...(id === undefined ? {} : { id }),
...(n === undefined ? {} : { n: Number(n) }),
};
console.log(`[notify:test] sending ${JSON.stringify(body)}`);
const res = await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const out = (await res.json()) as { ok?: boolean; error?: string; results?: Array<Record<string, unknown>> };
if (!res.ok || out.error) {
console.error(`[notify:test] refused (${res.status}): ${out.error ?? 'unknown'}`);
process.exit(1);
}
const results = out.results ?? [];
if (results.length === 0) {
console.error('[notify:test] no channel took it — nothing configured, or no device registered for this user');
process.exit(1);
}
let delivered = 0;
for (const r of results) {
const sent = Number(r.sent ?? 0);
delivered += sent;
const dead = Array.isArray(r.dead) && r.dead.length ? `, ${r.dead.length} dead token(s) deleted` : '';
console.log(`[notify:test] ${r.channel}: sent ${sent}, failed ${r.failed}${dead}`);
}
// `sent: 0, failed: 0` is the quiet one: the channel is configured but the user has no device of that
// platform registered. Worth naming, because it looks like success.
if (delivered === 0) {
console.error('[notify:test] nothing was delivered — is a device registered for this user?');
console.error(' check: GET /api/notify/_officer/devices');
process.exit(1);
}
console.log(`[notify:test] delivered to ${delivered} device(s) — look at your phone`);
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bun
/**
* Poke a chat session on a timer, but ONLY when it is idle.
*
* bun run nudge -- --session <id> --every 3600 --message "Continue. If blocked, write it down and move on."
* bun run nudge -- --list # which sessions exist, and which are working
*
* ── What this is for ──
*
* An agent left running overnight. It is told to record blockers and continue rather than wait for a human,
* and mostly it does — but a turn that ends leaves it idle until someone types, and at 3am nobody does.
* This is the someone.
*
* ── Why it is a script and not a queue job ──
*
* The platform has no recurring scheduler and no way to put a prompt into a live session: `pushTurn` has
* exactly one caller, the chat websocket. Adding both is real work in the queue engine and the chat API,
* and doing it tonight would mean editing a repository an agent is currently working in.
*
* So this talks to the same doors a browser does — `GET /api/chat/sessions` to see the state, then the chat
* websocket to speak. Nothing new is exposed and nothing in the platform changes. If the loop is ever worth
* making permanent, it belongs in the queue with a real trigger, and this script is what that would replace.
*
* ── The one rule ──
*
* NEVER interrupt work. A nudge sent mid-turn is at best noise and at worst a derail, so `isGenerating` is
* checked immediately before speaking and a working session is skipped silently. The point is to restart a
* stopped agent, not to hurry a busy one.
*/
import { sign } from '@@/jwt';
import { getOwnerUser } from 'officerdb';
const arg = (name: string): string | undefined => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? undefined : process.argv[i + 1];
};
const has = (name: string) => process.argv.includes(`--${name}`);
const PORT = process.env.OFFICERDEV_PORT ?? process.env.PORT ?? '9000';
const BASE = `http://127.0.0.1:${PORT}`;
const owner = await getOwnerUser();
if (!owner) {
console.error('[nudge] no owner account — nothing to sign as');
process.exit(1);
}
// Minted here rather than asked for: this runs on the box, as the owner, and a token pasted into a shell
// is a token in the shell history.
const token = await sign({ id: owner.id, email: owner.email, username: owner.username }, '2d');
type Session = { id: string; title?: string; isGenerating?: boolean; updatedAt?: string };
async function sessions(): Promise<Session[]> {
// `?all=1` — every working directory, not just the default one. Without it the list is scoped to the
// owner's home and an agent started in a project directory is simply absent, which reads as "the session
// is gone" and stops the loop. This is the list's own Latest tab, so the shape is identical.
const res = await fetch(`${BASE}/api/chat/sessions?all=1`, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`GET /api/chat/sessions → ${res.status}`);
const body = (await res.json()) as { sessions?: Session[] } | Session[];
return Array.isArray(body) ? body : (body.sessions ?? []);
}
if (has('list')) {
for (const s of await sessions()) {
console.log(`${s.isGenerating ? 'WORKING ' : 'idle '} ${s.id} ${s.title ?? ''}`);
}
process.exit(0);
}
const sessionId = arg('session');
const message =
arg('message') ?? 'Continue with the next task. If you are blocked, write the blocker down and move on.';
const every = Number(arg('every') ?? 3600);
/** A ceiling, because a loop nobody is watching should end on its own. Twelve hours by default. */
const max = Number(arg('max') ?? 12);
if (!sessionId) {
console.error('[nudge] --session <id> is required. Use --list to find one.');
process.exit(2);
}
/** Send one prompt, the way the browser does. Resolves once the server acknowledges the turn. */
function nudge(): Promise<void> {
return new Promise((resolve, reject) => {
const proto = 'ws';
const ws = new WebSocket(`${proto}://127.0.0.1:${PORT}/api/chat/ws?token=${token}&sessionId=${sessionId}`);
const done = (err?: Error) => {
try {
ws.close();
} catch {
/* already closing */
}
err ? reject(err) : resolve();
};
const timer = setTimeout(() => done(new Error('no acknowledgement within 30s')), 30_000);
ws.onopen = () => ws.send(JSON.stringify({ type: 'chat', prompt: message, sessionId }));
ws.onmessage = (ev) => {
// Anything that is not an error means the turn was taken. We do not wait for it to FINISH — that is
// the agent's night, not ours.
try {
const msg = JSON.parse(String(ev.data)) as { type?: string; message?: string };
if (msg.type === 'error') {
clearTimeout(timer);
return done(new Error(msg.message ?? 'server refused the prompt'));
}
if (msg.type === 'assistant:delta' || msg.type === 'assistant:text' || msg.type === 'tool:start') {
clearTimeout(timer);
return done();
}
} catch {
/* not json — ignore */
}
};
ws.onerror = () => {
clearTimeout(timer);
done(new Error('websocket error'));
};
});
}
const stamp = () => new Date().toISOString().slice(11, 19);
console.log(`[nudge] session ${sessionId} — every ${every}s, at most ${max} times`);
console.log(`[nudge] message: ${message}`);
for (let sent = 0, tick = 0; sent < max; tick++) {
if (tick > 0) await Bun.sleep(every * 1000);
let state: Session | undefined;
try {
state = (await sessions()).find((s) => s.id === sessionId);
} catch (err) {
console.error(`[nudge ${stamp()}] could not read sessions: ${err instanceof Error ? err.message : err}`);
continue; // a server restart is not a reason to give up on the night
}
if (!state) {
console.error(`[nudge ${stamp()}] session ${sessionId} is gone — stopping`);
break;
}
if (state.isGenerating) {
console.log(`[nudge ${stamp()}] working — skipped`);
continue;
}
try {
await nudge();
sent++;
console.log(`[nudge ${stamp()}] idle — nudged (${sent}/${max})`);
} catch (err) {
console.error(`[nudge ${stamp()}] failed: ${err instanceof Error ? err.message : err}`);
}
}
console.log(`[nudge ${stamp()}] finished`);
process.exit(0);
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env bun
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join } from 'node:path';
import { mapPalette } from './themes/map';
import type { Palette } from './themes/palette';
import { paletteFromRecord, parseTomlText } from './themes/palette';
import { deriveOmarchyPalette } from './themes/from-ansi';
import { parseAlacrittyToml, parseColor16Toml, parseGhosttyConf } from './themes/ansi-source';
import { contrast, ensureContrast, TEXT, UI } from './themes/contrast';
// Port community Omarchy themes from omarchythemes.com into the same 84-token shape the official
// pipeline produces — see `generate-themes.ts` for that one. This is its sibling for themes that have no
// `colors.toml`: a single-theme repo instead ships per-app configs, so `from-ansi.ts` reconstructs the
// same `Palette` from `alacritty.toml` or `ghostty.conf`, and everything downstream (mapping, contrast
// checking) is identical. What's NOT identical: these are unofficial, one author's repo can vanish or
// change, and the reconstruction is a heuristic, not Omarchy's own curated palette — every generated file
// says so in its header.
//
// bun scripts/port-community-themes.ts [--limit=N] [--slug=<one-slug>] [--redo]
//
// State lives in `scripts/themes/community-manifest.json` — one entry per theme successfully ported, so a
// re-run only fetches what's missing (or everything, with --redo) and the registry/index blocks are
// always rewritten fresh from the whole manifest, never hand-edited.
const CSS_THEME_DIR = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'css', 'themes');
const INDEX_CSS = join(CSS_THEME_DIR, '..', 'index.css');
const REGISTRY = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'src', 'themes.ts');
const MANIFEST_PATH = join(import.meta.dir, 'themes', 'community-manifest.json');
const SITE = 'https://omarchythemes.com';
const CONCURRENCY = 8;
const REDO = process.argv.includes('--redo');
const ONLY_SLUG = process.argv.find((a) => a.startsWith('--slug='))?.split('=')[1];
const LIMIT = Number(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] ?? Infinity);
type ManifestEntry = {
id: string;
name: string;
repo: string;
/** Omitted means GitHub, which is where all but a couple of these live. */
host?: 'gitlab';
kind: string;
dark: boolean;
};
type Repo = { host: 'github' | 'gitlab'; path: string };
const repoUrl = (r: Repo): string => `https://${r.host === 'gitlab' ? 'gitlab.com' : 'github.com'}/${r.path}`;
// Same pairs `generate-themes.ts` checks, kept in sync by hand since this is the only other place that
// needs them and duplicating a 20-line array beats a shared module for one caller each.
const PAIRS: [string, string, number][] = [
['--foreground', '--background', TEXT],
['--card-foreground', '--card', TEXT],
['--popover-foreground', '--popover', TEXT],
['--muted-foreground', '--muted', TEXT],
['--accent-foreground', '--accent', TEXT],
['--primary-foreground', '--primary', TEXT],
['--secondary-foreground', '--secondary', TEXT],
['--cta-foreground', '--cta', TEXT],
['--success-foreground', '--success', TEXT],
['--warning-foreground', '--warning', TEXT],
['--destructive-foreground', '--destructive', TEXT],
['--info-foreground', '--info', TEXT],
['--busy-foreground', '--busy', TEXT],
['--busy-ink', '--background', TEXT],
['--input', '--background', 1.35],
['--ring', '--background', UI],
];
const title = (id: string): string =>
id
.split('-')
.map((w) => (w.length <= 2 ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
.join(' ');
function loadManifest(): ManifestEntry[] {
if (!existsSync(MANIFEST_PATH)) return [];
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
}
function saveManifest(entries: ManifestEntry[]): void {
writeFileSync(MANIFEST_PATH, `${JSON.stringify(entries, null, 2)}\n`);
}
function ghApiDefaultBranch(repo: string): string {
try {
const out = execSync(`gh api repos/${repo} --jq .default_branch`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return out.trim() || 'main';
} catch {
return 'main';
}
}
async function gitlabDefaultBranch(path: string): Promise<string> {
const json = await fetchText(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`);
if (!json) return 'main';
try {
return (JSON.parse(json).default_branch as string) || 'main';
} catch {
return 'main';
}
}
async function fetchText(url: string): Promise<string | undefined> {
try {
const res = await fetch(url);
if (!res.ok) return undefined;
return await res.text();
} catch {
return undefined;
}
}
async function discoverRepo(slug: string): Promise<Repo | undefined> {
const html = await fetchText(`${SITE}/themes/${slug}`);
if (!html) return undefined;
const gh = html.match(/https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)/);
if (gh) return { host: 'github', path: `${gh[1]}/${gh[2]}` };
// A couple of authors host on GitLab instead. Its group paths can nest, hence the greedier match.
const gl = html.match(/https:\/\/gitlab\.com\/([\w.-]+(?:\/[\w.-]+)+?)(?=["'\s<]|$)/);
if (gl) return { host: 'gitlab', path: gl[1]!.replace(/\.git$/, '') };
return undefined;
}
async function sourceFor(
repo: Repo,
slug: string,
): Promise<{ palette: Palette; kind: string; derived: boolean } | undefined> {
const branch = repo.host === 'gitlab' ? await gitlabDefaultBranch(repo.path) : ghApiDefaultBranch(repo.path);
const raw = (p: string) =>
repo.host === 'gitlab'
? `https://gitlab.com/${repo.path}/-/raw/${branch}/${p}`
: `https://raw.githubusercontent.com/${repo.path}/${branch}/${p}`;
// Most repos hold one theme at the root. A few are packs that nest one directory per theme, so also
// look under the directory named after THIS slug — an exact match is the only safe pick, since a pack
// with variant names of its own (`abyss-dark`, `lagoon-light`, …) has no one theme that is "the" one.
const prefixes = ['', `themes/${slug}/`, `omarchy/themes/${slug}/`, `${slug}/`];
for (const prefix of prefixes) {
// `colors.toml` is ambiguous across community repos: it might genuinely be Omarchy 4's own semantic
// schema (in which case no derivation is needed at all — same quality as the official 22), or it might
// reuse the filename for an unrelated flat `color0..color15` ANSI scheme. Try the real schema first;
// only fall through to the ANSI reading if the required keys aren't there.
const tomlText = await fetchText(raw(`${prefix}colors.toml`));
if (tomlText) {
const kv = parseTomlText(tomlText);
try {
return {
palette: paletteFromRecord(kv, `${repo.path}/${prefix}colors.toml`),
kind: 'colors.toml (Omarchy schema)',
derived: false,
};
} catch {
const ansi = parseColor16Toml(kv);
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'colors.toml (16-colour ANSI)', derived: true };
}
}
for (const path of [
'alacritty.toml',
'.config/alacritty.toml',
'alacritty/alacritty.toml',
'config/alacritty.toml',
]) {
const text = await fetchText(raw(`${prefix}${path}`));
if (!text) continue;
const ansi = parseAlacrittyToml(text);
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'alacritty.toml', derived: true };
}
for (const path of ['ghostty.conf', 'ghostty-theme', '.config/ghostty/config']) {
const text = await fetchText(raw(`${prefix}${path}`));
if (!text) continue;
const ansi = parseGhosttyConf(text);
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'ghostty.conf', derived: true };
}
}
return undefined;
}
async function portOne(slug: string): Promise<{ entry: ManifestEntry; css: string } | { skip: string }> {
const repo = await discoverRepo(slug);
if (!repo) return { skip: 'no github/gitlab repo link found on its omarchythemes.com page' };
const source = await sourceFor(repo, slug);
if (!source) return { skip: `no parseable colors.toml/alacritty.toml/ghostty.conf in ${repo.path}` };
const palette = source.palette;
const tokens = mapPalette(palette);
// `--input` carries a real accessibility requirement (a field boundary you can't see) where `--border`
// is decoration and isn't held to one — see the README. `map.ts` derives both from the same mix for
// every theme, official ones included, and it normally clears 1.35:1 on its own; a few derived palettes
// land just under it, so nudge only this one rather than loosening the check.
tokens['--input'] = ensureContrast(tokens['--input']!, tokens['--background']!, 1.35);
const failures: string[] = [];
for (const [fgKey, bgKey, min] of PAIRS) {
const fg = tokens[fgKey]!;
const bg = tokens[bgKey]!;
if (!fg.startsWith('#') || !bg.startsWith('#')) continue;
const ratio = contrast(fg, bg);
if (ratio < min) failures.push(`${fgKey} on ${bgKey} is ${ratio.toFixed(2)}:1, needs ${min}:1`);
}
if (failures.length > 0) return { skip: `contrast failed after derivation: ${failures.join('; ')}` };
const name = title(slug);
const body = Object.entries(tokens)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n');
const modeLine = source.kind.startsWith('colors.toml (Omarchy')
? `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}) — the source repo's own colors.toml says so directly.`
: `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}), by luminance of its own background — the source repo carries no mode flag of its own to read.`;
const provenanceLine = source.derived
? `went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4\n colors.toml — some tokens here are reconstructed, not authored.`
: `came straight from the repo's own colors.toml — the same schema and reader the official 22 themes\n use, so nothing here is reconstructed.`;
const css = `/*
${name} — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
${modeLine}
Source: ${repoUrl(repo)} (${source.kind}), read by scripts/port-community-themes.ts. Values
${provenanceLine} Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='${slug}'] {
${body}
}
`;
return {
entry: {
id: slug,
name,
repo: repo.path,
...(repo.host === 'gitlab' ? { host: 'gitlab' as const } : {}),
kind: source.kind,
dark: palette.mode === 'dark',
},
css,
};
}
function rewriteIndexCss(entries: ManifestEntry[]): void {
const index = readFileSync(INDEX_CSS, 'utf8');
const marker = '/* Community themes';
const startIdx = index.indexOf(marker);
const head = startIdx >= 0 ? index.slice(0, startIdx) : index.replace(/\n@import '\.\/chrome\.css';\n$/, '\n');
const imports = entries.map((e) => `@import './themes/${e.id}.css';`).join('\n');
writeFileSync(
INDEX_CSS,
`${head}/* Community themes — ported from omarchythemes.com by scripts/port-community-themes.ts. Do not edit this block. */\n${imports}\n\n@import './chrome.css';\n`,
);
}
function rewriteRegistry(entries: ManifestEntry[]): void {
const registry = readFileSync(REGISTRY, 'utf8');
const START = ' /* COMMUNITY THEMES — start */';
const END = ' /* COMMUNITY THEMES — end */';
const rows = entries
.map((e) => ` { id: '${e.id}', name: '${e.name}', family: '${e.name}', applyDarkClass: ${e.dark} },`)
.join('\n');
const block = `${START}\n${rows}\n${END}`;
if (registry.includes(START) && registry.includes(END)) {
// Snap to the start of the marker's own line — `indexOf` on a string with leading spaces can match
// partway into stray indentation left by an earlier write, which would otherwise accumulate.
const startLineBegin = registry.lastIndexOf('\n', registry.indexOf(START)) + 1;
const before = registry.slice(0, startLineBegin);
const after = registry.slice(registry.indexOf(END) + END.length);
writeFileSync(REGISTRY, `${before}${block}${after}`);
} else {
const anchor = '] as const;';
const idx = registry.indexOf(anchor);
writeFileSync(REGISTRY, `${registry.slice(0, idx)}${block}\n${registry.slice(idx)}`);
}
}
async function main() {
const slugsFile = process.argv.find((a) => a.startsWith('--slugs='))?.split('=')[1];
const allSlugs: string[] = slugsFile ? readFileSync(slugsFile, 'utf8').trim().split('\n').filter(Boolean) : [];
const manifest = loadManifest();
const known = new Set(manifest.map((e) => e.id));
// Never take a slug the OFFICIAL/hand-authored part of the registry already owns — that's everything
// before this script's own block, which it is free to redo.
const registryHead = readFileSync(REGISTRY, 'utf8').split('/* COMMUNITY THEMES — start */')[0]!;
const registryIds = new Set(Array.from(registryHead.matchAll(/id: '([\w-]+)'/g)).map((m) => m[1]!));
let todo = ONLY_SLUG ? [ONLY_SLUG] : allSlugs.filter((s) => !registryIds.has(s) && (REDO || !known.has(s)));
todo = todo.slice(0, LIMIT);
console.log(`community theme port: ${todo.length} to try, ${manifest.length} already in the manifest`);
const results: ManifestEntry[] = [...manifest.filter((e) => !todo.includes(e.id))];
let done = 0;
let ok = 0;
let skipped = 0;
async function worker() {
while (todo.length > 0) {
const slug = todo.shift();
if (!slug) break;
try {
const result = await portOne(slug);
done++;
if ('skip' in result) {
skipped++;
console.log(`[${done}] SKIP ${slug} — ${result.skip}`);
} else {
writeFileSync(join(CSS_THEME_DIR, `${slug}.css`), result.css);
results.push(result.entry);
ok++;
console.log(`[${done}] OK ${slug} — from ${result.entry.repo} (${result.entry.kind})`);
}
} catch (err) {
done++;
skipped++;
console.log(`[${done}] ERROR ${slug} — ${(err as Error).message}`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
results.sort((a, b) => a.id.localeCompare(b.id));
saveManifest(results);
rewriteIndexCss(results);
rewriteRegistry(results);
console.log(`\ndone: ${ok} ported, ${skipped} skipped, ${results.length} total in manifest`);
}
main();
+19 -10
View File
@@ -12,14 +12,21 @@
// Docker container. That architecture is gone. What survives from it is the useful part: a user has a // Docker container. That architecture is gone. What survives from it is the useful part: a user has a
// root, and a home inside it. // root, and a home inside it.
// //
// Idempotent — an existing directory is left exactly as it is, so re-running is safe. // Idempotent — an existing directory keeps its contents, so re-running is safe. It does NOT keep its
// mode: provisionUserDirs re-asserts 711/700 on every run, tolerating the EPERM from a `home` that has
// since become the member's own. Re-running is therefore also the repair.
import { mkdirSync, existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
// The list and DATA_PATH itself come from the platform rather than being restated here. The owner's // The list and DATA_PATH itself come from the platform rather than being restated here. The owner's
// create-account handler provisions the same skeleton, and a script that drifted from it would produce // create-account handler provisions the same skeleton, and a script that drifted from it would produce
// accounts that differ by how they were made. Importing data-path.ts pulls in no database and no server. // accounts that differ by how they were made. Importing data-path.ts pulls in no database and no server.
import { DATA_PATH, USER_DIRS } from '../src/servers/data-path'; // provisionUserDirs rather than a local mkdir loop, and that is the whole point of this script: it used
// to import DATA_PATH and USER_DIRS from this very module and then reimplement the loop WITHOUT the
// chmod, so a hand-provisioned account got 755 where a UI-provisioned one got 711/700. Nothing was ever
// created that way here, but "two paths that claim to produce identical accounts" is exactly the drift
// the comment above already warned about — while doing it.
import { DATA_PATH, USER_DIRS, provisionUserDirs } from '../src/servers/data-path';
const DRY_RUN = process.env.DRY_RUN === '1'; const DRY_RUN = process.env.DRY_RUN === '1';
const emails = process.argv.slice(2).filter(Boolean); const emails = process.argv.slice(2).filter(Boolean);
@@ -44,14 +51,16 @@ for (const email of emails) {
const root = join(DATA_PATH, email); const root = join(DATA_PATH, email);
console.log(`${email}${existsSync(root) ? '' : ' [new root]'}`); console.log(`${email}${existsSync(root) ? '' : ' [new root]'}`);
// Probed BEFORE provisioning, because afterwards everything exists and the log would say so.
const existing = new Set(USER_DIRS.filter((dir) => existsSync(join(root, dir))));
// Skipped entirely under DRY_RUN rather than called: provisionUserDirs has no dry-run mode, and giving
// it one to serve this script would put a branch in the real provisioning path for a script's benefit.
if (!DRY_RUN) provisionUserDirs(email);
for (const dir of USER_DIRS) { for (const dir of USER_DIRS) {
const path = join(root, dir); if (existing.has(dir)) console.log(` · ${dir} (exists${DRY_RUN ? '' : ', mode re-asserted'})`);
if (existsSync(path)) { else console.log(` ${DRY_RUN ? '+' : '✓'} ${dir}`);
console.log(` · ${dir} (exists)`);
continue;
}
if (!DRY_RUN) mkdirSync(path, { recursive: true });
console.log(` ${DRY_RUN ? '+' : '✓'} ${dir}`);
} }
console.log(''); console.log('');
} }
-52
View File
@@ -1,52 +0,0 @@
// Re-derive the stored tree shape for every cached share snapshot, from the folder rows already in
// Postgres — no peer contact, so it works with everyone offline and is deterministic.
//
// Written for the backfill when the tree columns were added, but kept because it's the answer to any
// future change in tree shape: it runs the SAME buildTree + finishSoulseekBrowse the sidecar's ingest
// runs, so there's no second implementation to drift.
//
// bun scripts/rebuild-soulseek-tree.ts
import type { BrowsedFile } from 'officerdb';
import { eq, asc } from 'drizzle-orm';
import { db, finishSoulseekBrowse } from 'officerdb';
// soulseek is a plugin, so its tables are commented out of officerdb's schema aggregator —
// import them from the feature directly.
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/soulseek/schema';
import { buildTree } from '../src/servers/sidecar/slskd/browse';
const snapshots = await db
.select({
id: soulseekBrowseSnapshots.id,
userId: soulseekBrowseSnapshots.userId,
username: soulseekBrowseSnapshots.username,
status: soulseekBrowseSnapshots.status,
})
.from(soulseekBrowseSnapshots)
.orderBy(asc(soulseekBrowseSnapshots.id));
console.log(`${snapshots.length} snapshot(s) to rebuild`);
for (const snap of snapshots) {
const started = Date.now();
const rows = await db
.select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files })
.from(soulseekBrowseDirs)
.where(eq(soulseekBrowseDirs.snapshotId, snap.id));
if (!rows.length) {
console.log(` #${snap.id} ${snap.username}: no folder rows, skipped`);
continue;
}
const tree = buildTree(rows.map((r) => ({ name: r.name, files: r.files as BrowsedFile[] })));
await finishSoulseekBrowse(snap.id, tree);
const roots = tree.filter((t) => t.parentPath === null);
console.log(
` #${snap.id} ${snap.username}: ${rows.length} -> ${tree.length} rows ` +
`(+${tree.length - rows.length} synthesized), ${roots.length} root(s), ` +
`${roots.reduce((n, r) => n + r.subtreeFileCount, 0)} files, ${Date.now() - started}ms`,
);
}
process.exit(0);
@@ -37,7 +37,7 @@ import postgres from 'postgres';
// `role === 'Super Admin'` before it reads the table at all, so the account that can fix things can // `role === 'Super Admin'` before it reads the table at all, so the account that can fix things can
// always sign in. Non-owners degrade to core-only until the rename completes. // always sign in. Non-owners degrade to core-only until the rename completes.
const url = process.env.POSTGRES_URL; const url = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!url) { if (!url) {
console.error(' POSTGRES_URL is not set. Run from the platform directory so Bun loads .env.'); console.error(' POSTGRES_URL is not set. Run from the platform directory so Bun loads .env.');
process.exit(1); process.exit(1);
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bun
/**
* Put a secret the platform did not generate into the secret store.
*
* bun run secret:set apns-p8 < AuthKey_ABCD123456.p8
* pbpaste | bun run secret:set fcm-service-account
*
* ── Why stdin, and no value argument ──
*
* A secret passed as `argv` is in the shell history, in `ps` output for the life of the process, and in
* any audit log that records command lines. Stdin is none of those. There is deliberately no `--value`
* flag: offering one would mean the convenient path is the leaky one.
*
* Multi-line input is kept verbatim, which is what a PEM needs — no escaping, unlike `.env`.
*/
import { putSecret, getSecret, secretStorePath } from '../src/databases/officer_db/src/secret-store';
const purpose = process.argv[2];
if (!purpose || purpose.startsWith('-')) {
console.error('usage: bun run secret:set <purpose> < file');
console.error(' cat AuthKey_ABCD123456.p8 | bun run secret:set apns-p8');
process.exit(2);
}
const value = (await Bun.stdin.text()).trim();
if (!value) {
console.error(`[secret:set] nothing on stdin — refusing to store an empty secret for '${purpose}'`);
console.error(' (did you mean `bun run secret:set <purpose> < file`?)');
process.exit(1);
}
const previous = putSecret(purpose, value);
// The value itself is never printed, here or anywhere. Enough to confirm the right thing landed:
// the length and last four characters identify a paste without reproducing it.
const fingerprint = `${value.length} chars, ending …${value.slice(-4)}`;
if (previous === null) {
console.log(`[secret:set] '${purpose}' stored — ${fingerprint}`);
} else if (previous === value) {
console.log(`[secret:set] '${purpose}' unchanged — ${fingerprint}`);
} else {
console.log(`[secret:set] '${purpose}' REPLACED — ${fingerprint}`);
console.log(` the previous value is retired, not deleted (retiredKeys('${purpose}') has it)`);
}
console.log(`[secret:set] store: ${secretStorePath()}`);
console.log(`[secret:set] read back: ${getSecret(purpose) === value ? 'ok' : 'MISMATCH — do not restart anything'}`);
+53
View File
@@ -0,0 +1,53 @@
/**
* Seed the `roles` table with the roles the platform's own code names.
*
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
* mechanism `backfill-session-models.ts` relies on.
*
* ── Why these four are `isSystem` ──
*
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
*
* ── What this deliberately does NOT do ──
*
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
* and adds the constraint in the same run, so every existing account violates it — verified against a
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
*
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
* unattended.
*
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
*/
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
const SEED: { name: string; description: string }[] = [
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
];
async function main(): Promise<void> {
await waitForDatabase();
let created = 0;
for (const role of SEED) {
if (await seedRole({ ...role, isSystem: true })) created += 1;
}
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
}
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
// a successful push look like a failed one — the rows are additive and the next push tries again.
main()
.catch((err: unknown) => {
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
})
.finally(() => process.exit(0));
+81 -4
View File
@@ -206,7 +206,7 @@ nvim_clone_config() {
# every process here. officer-pty imports node-pty, a native addon with # every process here. officer-pty imports node-pty, a native addon with
# no Linux prebuild — it compiles against the installed Node on every # no Linux prebuild — it compiles against the installed Node on every
# machine. Either one alone makes Node load-bearing. # machine. Either one alone makes Node load-bearing.
# bun the platform itself and nineteen of the twenty pm2 apps. # bun the platform itself, and every pm2 app except officer-pty.
# pm2 the process manager the ecosystem files are written for. # pm2 the process manager the ecosystem files are written for.
# #
# Deno is not. Nothing in the platform imports it — checked across the whole # Deno is not. Nothing in the platform imports it — checked across the whole
@@ -243,6 +243,69 @@ install_node() {
npm config set prefix /usr/local >/dev/null 2>&1 || true npm config set prefix /usr/local >/dev/null 2>&1 || true
} }
# ── Held at the 1.3 series, deliberately and temporarily ──
#
# bun 1.4.0 landed on 2026-08-20 and https://bun.sh/install takes whatever is
# newest, so a machine provisioned two days later puts a brand-new release under
# the platform and every pm2 app it runs. Held at 1.3 until 1.4 has been watched
# for a while. That is the owner's call and it is meant to be revisited in a few
# months, not a permanent ceiling — raise BUN_SERIES when 1.4 has earned it.
#
# The SERIES is pinned, not a patch. 1.3.x fixes still land and should still be
# taken; a hardcoded 1.3.14 would rot into "the version we happened to pick"
# exactly the way setup_22.x did for Node.
#
# This covers the INSTALL path only, and cannot cover the other one: `bun upgrade`
# takes no version argument — checked against `bun upgrade --help` on 1.3.14, which
# offers latest-stable or --canary and nothing else — so it always jumps to the
# newest release and will walk a pinned machine onto 1.4 on its own. Nothing in a
# setup script can prevent that. `bun run doctor` is where that drift is caught.
BUN_SERIES="${BUN_SERIES:-1.3}"
# The newest stable release inside the series.
#
# Asked of the npm registry rather than GitHub's releases API, which is the obvious
# source and the wrong one: it rate-limits unauthenticated callers to 60 an hour per
# IP, and it returned 504 twice while this was being written. An empty answer here
# means no bun gets installed at all (see install_bun), so where it comes from is
# load-bearing rather than a matter of taste. bun publishes every release to npm.
#
# `sort -V` rather than trusting the order the registry returns them in, and the
# pattern is anchored so 1.3.1 cannot be mistaken for the newest when 1.3.14 exists.
bun_series_latest() {
curl -fsSL https://registry.npmjs.org/bun 2>/dev/null |
jq -r '.versions | keys[]' 2>/dev/null |
grep -E "^${BUN_SERIES//./\\.}\.[0-9]+$" |
sort -V | tail -1
}
# The major.minor of a full version — "1.3.14" is in series "1.3".
bun_series_of() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"${1:-}"; }
# Where an installed bun sits relative to the pin: in, above, below, or unknown.
#
# Above and below are different problems and get different answers — above is a
# machine that has upgraded itself past the pin, below is one that is simply old —
# so this reports which rather than a yes/no. `sort -V` does the comparing, so
# nothing here has to parse a version.
bun_series_position() {
local now
now="$(bun_series_of "${1:-}")"
[[ -n "$now" ]] || {
echo unknown
return
}
[[ "$now" == "$BUN_SERIES" ]] && {
echo in
return
}
if [[ "$(printf '%s\n%s\n' "$now" "$BUN_SERIES" | sort -V | tail -1)" == "$now" ]]; then
echo above
else
echo below
fi
}
# Present anywhere: on PATH for this root shell, or in the account's own # Present anywhere: on PATH for this root shell, or in the account's own
# ~/.bun/bin, which is where the installer puts it and where root cannot see it. # ~/.bun/bin, which is where the installer puts it and where root cannot see it.
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; } bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
@@ -260,8 +323,8 @@ bun_version() {
# The system-wide link, ensured on every run rather than only after an install. # The system-wide link, ensured on every run rather than only after an install.
# #
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its # pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'` # PATH — and every ecosystem app that says `script: 'bun'` then fails to start on
# then fails to start on reboot while working perfectly when started by hand. A # reboot while working perfectly when started by hand. A
# machine that already had bun before this script ran would never get the link if # machine that already had bun before this script ran would never get the link if
# it were only made as part of installing. # it were only made as part of installing.
# #
@@ -280,8 +343,22 @@ ensure_bun_symlink() {
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the # systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
# symlink every bun-based sidecar fails to start on reboot and works fine when # symlink every bun-based sidecar fails to start on reboot and works fine when
# started by hand, which is a miserable thing to debug. # started by hand, which is a miserable thing to debug.
#
# The version goes in as the installer's first positional argument, which it
# documents as "a specific tag of bun to install (e.g. bun-v0.1.4)" and turns into a
# releases/download/<tag>/ URL. `bash -s` is what carries an argument through a pipe.
# The installer handles being pointed at an older tag on purpose — it re-probes AVX2
# rather than assuming the baseline the newest release was built for — so this both
# installs and pins back down.
#
# It REFUSES to run unpinned. An empty tag means bun_series_latest could not reach
# the registry, and installing whatever is newest is the single outcome the pin
# exists to prevent, so a machine with no bun is the better failure: it is loud, and
# officer-setup's pre-flight stops on a missing bun rather than building on 1.4.
install_bun() { install_bun() {
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1 local tag="${1:-}"
[[ -n "$tag" ]] || return 1
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
[[ -x "${USER_HOME}/.bun/bin/bun" ]] [[ -x "${USER_HOME}/.bun/bin/bun" ]]
} }
+4 -4
View File
@@ -103,23 +103,23 @@ pkgs_core() {
echo curl ca-certificates gnupg git jq unzip \ echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \ apt-transport-https lsb-release software-properties-common \
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \ wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
poppler-utils fail2ban unattended-upgrades poppler-utils radicale fail2ban unattended-upgrades
;; ;;
pacman) pacman)
echo curl ca-certificates gnupg git jq unzip \ echo curl ca-certificates gnupg git jq unzip \
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \ wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
poppler fail2ban poppler radicale fail2ban
;; ;;
dnf) dnf)
echo curl ca-certificates gnupg2 git jq unzip \ echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \ wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
poppler-utils fail2ban poppler-utils radicale fail2ban
;; ;;
brew) brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from # curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula — see xcode_clt_*. # the Xcode command line tools, which is not a formula — see xcode_clt_*.
# brotli is here because macOS ships the library but not the CLI. # brotli is here because macOS ships the library but not the CLI.
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler radicale
;; ;;
esac esac
} }
+88 -9
View File
@@ -1981,14 +1981,18 @@ step "JavaScript runtimes"
if ! skip; then if ! skip; then
NODE_LTS="$(node_lts_major)" NODE_LTS="$(node_lts_major)"
NODE_NOW="$(node_installed_major)" NODE_NOW="$(node_installed_major)"
# Resolved before the banner so it can state the pin rather than only the version
# that happens to be here. One network call, same as node_lts_label above it.
BUN_WANT="$(bun_series_latest)"
BUN_NOW="$(bun_version)"
echo "" echo ""
info "JavaScript runtimes — required, so these are installed rather than offered" info "JavaScript runtimes — required, so these are installed rather than offered"
echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)" echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
echo " pm2 is a Node application, and officer-pty compiles node-pty" echo " pm2 is a Node application, and officer-pty compiles node-pty"
echo " against whatever Node is installed — there is no Linux prebuild." echo " against whatever Node is installed — there is no Linux prebuild."
echo " bun $(bun_installed && bun_version || echo 'not installed')" echo " bun ${BUN_NOW:-not installed} — pinned to the ${BUN_SERIES} series${BUN_WANT:+, newest is ${BUN_WANT}}"
echo " the platform itself, and nineteen of the twenty pm2 apps." echo " the platform itself, and every pm2 app except officer-pty."
echo " pm2 $(pm2_installed && echo installed || echo 'not installed')" echo " pm2 $(pm2_installed && echo installed || echo 'not installed')"
echo " supervises all of them, and the ecosystem files are written for it." echo " supervises all of them, and the ecosystem files are written for it."
echo "" echo ""
@@ -2014,18 +2018,93 @@ if ! skip; then
fi fi
fi fi
# ── bun ── # ── bun, held at the pinned series ──
if bun_installed; then #
ok "bun $(bun_version) already installed" # Four states, because "installed" is not one answer. A machine can be inside the
else # pin, behind it, or past it — and past it is the case worth catching, since that
info " installing bun..." # is what a `bun upgrade` leaves behind and nothing else reports it.
if install_bun && bun_installed; then if [[ -z "$BUN_WANT" ]]; then
# No pin resolved means no install: taking whatever bun.sh serves is the one
# thing the pin exists to stop.
warn "could not reach the npm registry to find the newest bun ${BUN_SERIES}.x"
if bun_installed; then
echo " leaving bun ${BUN_NOW} where it is"
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; left ${BUN_NOW} alone")
else
echo " nothing installed — this will not install an unpinned bun, and"
echo " officer-setup stops on a missing bun rather than building on 1.4."
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; nothing installed")
fi
elif ! bun_installed; then
info " installing bun ${BUN_WANT}, pinned to the ${BUN_SERIES} series..."
if install_bun "bun-v${BUN_WANT}" && bun_installed; then
ok "bun $(bun_version)" ok "bun $(bun_version)"
SUMMARY+=("Bun: $(bun_version)") SUMMARY+=("Bun: $(bun_version) (pinned to ${BUN_SERIES}.x)")
else else
warn "the bun install did not complete" warn "the bun install did not complete"
ERRORS+=("Bun: install failed") ERRORS+=("Bun: install failed")
fi fi
else
case "$(bun_series_position "$BUN_NOW")" in
in)
if [[ "$BUN_NOW" == "$BUN_WANT" ]]; then
ok "bun ${BUN_NOW} — the newest in the pinned ${BUN_SERIES} series"
else
info "bun ${BUN_NOW} is inside the pin, and ${BUN_WANT} is out"
echo " A patch inside the series is what the pin is meant to let"
echo " through. Note that 'bun upgrade' would not stop there — it"
echo " takes the newest release, which is past the pin."
if confirm "Move to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not move to ${BUN_WANT}")
fi
else
echo " left at ${BUN_NOW}"
fi
fi
;;
above)
# The `bun upgrade` case. Reported rather than corrected silently: moving
# somebody's runtime backwards is a decision, and this is the only place
# that knows it happened.
warn "bun ${BUN_NOW} is PAST the pinned ${BUN_SERIES} series"
echo " Most likely a 'bun upgrade', which takes no version argument and"
echo " always jumps to the newest release. The platform and every pm2"
echo " app except officer-pty run on this."
if confirm "Pin it back to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version) — back inside the pin"
SUMMARY+=("Bun: pinned back, ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not pin ${BUN_NOW} back to ${BUN_WANT}")
fi
else
warn "left at ${BUN_NOW}, outside the pin"
SUMMARY+=("Bun: ${BUN_NOW}, PAST the ${BUN_SERIES} pin by choice")
fi
;;
*)
info "bun ${BUN_NOW} is older than the pinned ${BUN_SERIES} series"
if confirm "Move to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not move to ${BUN_WANT}")
fi
else
echo " left at ${BUN_NOW}"
fi
;;
esac
fi fi
# Ensured every run, not only after an install. A machine that already had bun # Ensured every run, not only after an install. A machine that already had bun
+17 -6
View File
@@ -293,6 +293,17 @@ if ! skip; then
create_layout create_layout
ok "layout in place under ${OFFICER_ROOT}" ok "layout in place under ${OFFICER_ROOT}"
SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)") SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)")
# The affordances an agent needs to know about this platform. Not fatal: an
# install without them works, agents just go back to guessing what the client
# on the other end will render — which is what they did before this existed.
if capabilities_sync; then
ok "capabilities synced from ${CAPABILITIES_REPO}"
SUMMARY+=("Capabilities: synced")
else
warn "capabilities not synced — agents will have no affordances. Run 'git pull' in $(layout_items_dir), or press Update all on the marketplace screen."
SUMMARY+=("Capabilities: NOT synced")
fi
step_ok step_ok
fi fi
@@ -749,7 +760,7 @@ if ! skip; then
echo " The ecosystem file is GENERATED, not checked in. It describes this" echo " The ecosystem file is GENERATED, not checked in. It describes this"
echo " install and nothing else, so nothing in git can drift from it." echo " install and nothing else, so nothing in git can drift from it."
echo "" echo ""
echo " six processes:" echo " ${#CORE_PROCESSES[@]} processes:"
for entry in "${CORE_PROCESSES[@]}"; do for entry in "${CORE_PROCESSES[@]}"; do
IFS='|' read -r _name _script _args <<<"$entry" IFS='|' read -r _name _script _args <<<"$entry"
printf " %-24s %s %s\n" "$_name" "$_script" "$_args" printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
@@ -761,7 +772,7 @@ if ! skip; then
if confirm "Write it and start them?"; then if confirm "Write it and start them?"; then
write_ecosystem write_ecosystem
ok "written — $(ecosystem_file)" ok "written — $(ecosystem_file)"
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")" report_changed "wrote $(ecosystem_file) — ${#CORE_PROCESSES[@]} pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
# Starting against a database that is not answering is not fatal — the server # Starting against a database that is not answering is not fatal — the server
# waits and the agent retries forever — but it makes the Verify section below # waits and the agent retries forever — but it makes the Verify section below
@@ -781,13 +792,13 @@ if ! skip; then
if pm2_enable_startup; then if pm2_enable_startup; then
ok "pm2 will resurrect them at boot" ok "pm2 will resurrect them at boot"
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot" report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
SUMMARY+=("Services: 6 processes started, enabled at boot") SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started, enabled at boot")
else else
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it" warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled") SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; boot hook NOT enabled")
fi fi
else else
SUMMARY+=("Services: 6 processes started; not enabled at boot") SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; not enabled at boot")
fi fi
else else
warn "pm2 did not start cleanly" warn "pm2 did not start cleanly"
@@ -934,7 +945,7 @@ fi
# #
# Root, and that surprises people — reasonably, because everything this script just # Root, and that surprises people — reasonably, because everything this script just
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout, # installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was # node_modules, .env, the secret store and every pm2 process are theirs. Root was
# the installer's privilege, never the platform's. # the installer's privilege, never the platform's.
# #
# Saying so matters for two things that are invisible until they bite: # Saying so matters for two things that are invisible until they bite:
+12 -3
View File
@@ -60,13 +60,22 @@ write_env() {
# #
# Everything Officer reads at runtime. Kept at 0600 and owned by ${USERNAME}: it # Everything Officer reads at runtime. Kept at 0600 and owned by ${USERNAME}: it
# holds the token-signing secret and the database credential. # holds the token-signing secret and the database credential.
#
# EVERY NAME IS PREFIXED \`OFFICERDEV_\`. Bun auto-loads this file into every
# process started in the platform directory, so a bare PORT or POSTGRES_URL
# reaches the sidecars, the tools they spawn and anything an agent runs from
# here — names half the software in the world already uses. Prefixed, they
# cannot be picked up by accident.
#
# Every consumer reads \`OFFICERDEV_X ?? X\`, so an older unprefixed file still
# works. \`bun run fix:env\` converts one in place.
PORT="${ENV_PORT}" OFFICERDEV_PORT="${ENV_PORT}"
# Where Officer is reached from a browser. Not derivable — see the section. # Where Officer is reached from a browser. Not derivable — see the section.
PUBLIC_URL="${ENV_PUBLIC_URL}" OFFICERDEV_PUBLIC_URL="${ENV_PUBLIC_URL}"
POSTGRES_URL="${POSTGRES_URL}" OFFICERDEV_POSTGRES_URL="${POSTGRES_URL}"
ENVF ENVF
+44
View File
@@ -61,3 +61,47 @@ layout_wrong_owner() {
[[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir" [[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir"
done < <(layout_dirs) done < <(layout_dirs)
} }
# -----------------------------------------------------------------------------
# The capabilities Officer ships
# -----------------------------------------------------------------------------
#
# `capabilities/` is the file-based item store. The platform creates the six item
# directories at boot and writes `extensions/tool-loader` from its own source, so
# those are generated and not tracked anywhere. What IS tracked is the small set
# the platform ships — today one affordance, telling an agent that markdown
# images render and that the user's DEVICE fetches the URL.
#
# Without this a fresh install has no affordances at all, and every agent on it
# goes back to guessing what the client on the other end will do.
#
# Cloned rather than copied out of the platform repository, so it can be updated
# on its own — by `git pull` here, or by "Update all" on the marketplace screen.
CAPABILITIES_REPO="https://gitea.officer.dev/officerdev/capabilities.git"
# Three states, because a directory that already exists is the normal one: the
# layout step created it a moment ago, and a re-run finds a checkout.
capabilities_sync() {
local dir; dir="$(layout_items_dir)"
if [[ -d "$dir/.git" ]]; then
# `HEAD` rather than a branch name — it resolves to the remote's default, so standardising the
# repositories on one name does not break this the way naming `main` did.
(cd "$dir" && git pull --ff-only --quiet origin HEAD 2>/dev/null) || return 1
return 0
fi
# An empty directory can be cloned into directly. A non-empty one cannot, and
# it is not empty precisely because the platform has already run and written
# its generated directories — so fetch into place instead of refusing, and let
# the owner's own untracked items sit alongside untouched.
if [[ -z "$(ls -A "$dir" 2>/dev/null)" ]]; then
git clone --quiet "$CAPABILITIES_REPO" "$dir" || return 1
else
(cd "$dir" \
&& git init --quiet \
&& git remote add origin "$CAPABILITIES_REPO" 2>/dev/null \
&& git fetch --quiet origin HEAD \
&& git checkout --quiet -B master FETCH_HEAD) || return 1
fi
}
+1 -1
View File
@@ -59,7 +59,7 @@ tool_why() {
case "$1" in case "$1" in
git) echo "to clone and update the platform" ;; git) echo "to clone and update the platform" ;;
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;; node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
bun) echo "the platform itself and nineteen of the twenty processes" ;; bun) echo "the platform itself and every process except the pty sidecar" ;;
pm2) echo "supervises every process; the ecosystem files are written for it" ;; pm2) echo "supervises every process; the ecosystem files are written for it" ;;
docker) echo "Postgres, and anything the app store provisions" ;; docker) echo "Postgres, and anything the app store provisions" ;;
*) echo "" ;; *) echo "" ;;
@@ -44,6 +44,22 @@ CORE_PROCESSES=(
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts" "officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts" "officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
"officer-pty|node|src/servers/sidecar/pty/index.mjs" "officer-pty|node|src/servers/sidecar/pty/index.mjs"
# Calendar and contacts, CORE since 2026-08-19 rather than an app-store entry.
#
# It was a Docker service because the app store made everything one, not because Radicale needs a
# container — it is a ~2MB Python package, and this sidecar spawns it directly on loopback. A personal
# server having a calendar is not an integration with something you already run; it is the baseline.
# Radicale needs `python3` and the `radicale` package, both installed by machine-setup.
"officer-caldav|bun|run src/servers/sidecar/caldav/index.ts"
# Push notifications, CORE since 2026-08-19. Built 2026-07-31, then switched off on 2026-08-13 with
# memos and photos when it was slated to become a plugin — the only one of the three never extracted,
# so it sat mounted nowhere with its schema commented out.
#
# 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.
# It boots with no credentials at all — every channel reports `isConfigured() === false` and dispatch
# resolves empty — so an install that never sets an APNs key runs it as a no-op rather than crash-looping.
"officer-notify|bun|run src/servers/sidecar/notify/index.ts"
) )
write_ecosystem() { write_ecosystem() {
+2 -9
View File
@@ -1,5 +1,5 @@
{ {
"src/apps/officer-web/Screens": 144, "src/apps/officer-web/Screens": 122,
"src/workspaces/components": 47, "src/workspaces/components": 47,
"src/workspaces/officerdev/src/apps/AppStore": 6, "src/workspaces/officerdev/src/apps/AppStore": 6,
"src/workspaces/officerdev/src/apps/Chat": 12, "src/workspaces/officerdev/src/apps/Chat": 12,
@@ -9,15 +9,8 @@
"src/workspaces/officerdev/src/apps/Dav": 7, "src/workspaces/officerdev/src/apps/Dav": 7,
"src/workspaces/officerdev/src/apps/FileBrowser": 15, "src/workspaces/officerdev/src/apps/FileBrowser": 15,
"src/workspaces/officerdev/src/apps/FileViewer": 38, "src/workspaces/officerdev/src/apps/FileViewer": 38,
"src/workspaces/officerdev/src/apps/Invoices": 95,
"src/workspaces/officerdev/src/apps/Jellyfin": 48,
"src/workspaces/officerdev/src/apps/Photos": 47,
"src/workspaces/officerdev/src/apps/Preview": 4, "src/workspaces/officerdev/src/apps/Preview": 4,
"src/workspaces/officerdev/src/apps/QrTransfer": 5, "src/workspaces/officerdev/src/apps/QrTransfer": 5,
"src/workspaces/officerdev/src/apps/Soulseek": 313,
"src/workspaces/officerdev/src/apps/SystemMonitor": 1, "src/workspaces/officerdev/src/apps/SystemMonitor": 1,
"src/workspaces/officerdev/src/apps/Terminal": 10, "src/workspaces/officerdev/src/apps/Terminal": 10
"src/workspaces/officerdev/src/apps/Transmission": 43,
"src/workspaces/officerdev/src/apps/Wallet": 68,
"src/workspaces/officerdev/src/components": 22
} }
+205
View File
@@ -0,0 +1,205 @@
// Reading a community theme's actual shipped format.
//
// Omarchy's own 22 themes carry a `colors.toml` in the semantic shape `palette.ts` expects — three
// background depths, four foreground weights, an accent, a selection, a muted. A community theme on
// omarchythemes.com ships none of that: it's per-app config files in the classic 16-ANSI-slot style,
// because that's what predates Omarchy 4's own format and what most single-theme repos still use.
//
// This file reads the two shapes that cover almost every repo: `alacritty.toml` (structured, typed,
// preferred) and `ghostty.conf`/`ghostty-theme` (flat `key = value` lines, fallback). Either becomes an
// `AnsiSource` — the 16 ANSI slots plus primary background/foreground — which `from-ansi.ts` turns into
// the same `Palette` shape the official pipeline consumes, so every downstream guarantee (contrast
// checking, the 84-token mapping) applies unchanged.
export type AnsiSource = {
background: string;
foreground: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
};
// `#rrggbb` is standard; `0xrrggbb` shows up in a handful of community alacritty.toml files (their
// authors apparently ported from a scheme that used C-style hex literals).
const HEX = /#[0-9a-fA-F]{3,8}|0x[0-9a-fA-F]{3,8}/;
function normalizeHex(v: string): string | undefined {
const m = v.match(HEX);
if (!m) return undefined;
let h = m[0].replace(/^(#|0x)/, '').toLowerCase();
if (h.length === 3)
h = h
.split('')
.map((c) => c + c)
.join('');
if (h.length >= 6) h = h.slice(0, 6);
return `#${h}`;
}
/** A deliberately small TOML reader: flat `key = "value"` lines plus `[section]` headers. */
export function parseAlacrittyToml(text: string): AnsiSource | undefined {
let section = '';
const rows: Record<string, Record<string, string>> = {};
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
const sectionMatch = line.match(/^\[([\w.]+)]$/);
if (sectionMatch) {
section = sectionMatch[1]!;
rows[section] ??= {};
continue;
}
// Quoted specifically, not "anything but a comment" — a hex value starts with `#`, the same
// character that opens a trailing comment, so the two can only be told apart by the quotes.
const kv = line.match(/^([\w-]+)\s*=\s*(['"])(.*?)\2/);
if (kv && section) {
const hex = normalizeHex(kv[3]!);
if (hex) rows[section]![kv[1]!] = hex;
}
}
const primary = rows['colors.primary'];
const normal = rows['colors.normal'];
const bright = rows['colors.bright'];
if (!primary?.background || !primary?.foreground || !normal || !bright) return undefined;
const need = (r: Record<string, string>, k: string): string | undefined => r[k];
const n = (k: string) => need(normal, k);
const b = (k: string) => need(bright, k);
if (
!n('black') ||
!n('red') ||
!n('green') ||
!n('yellow') ||
!n('blue') ||
!n('magenta') ||
!n('cyan') ||
!n('white')
)
return undefined;
if (
!b('black') ||
!b('red') ||
!b('green') ||
!b('yellow') ||
!b('blue') ||
!b('magenta') ||
!b('cyan') ||
!b('white')
)
return undefined;
return {
background: primary.background,
foreground: primary.foreground,
black: n('black')!,
red: n('red')!,
green: n('green')!,
yellow: n('yellow')!,
blue: n('blue')!,
magenta: n('magenta')!,
cyan: n('cyan')!,
white: n('white')!,
brightBlack: b('black')!,
brightRed: b('red')!,
brightGreen: b('green')!,
brightYellow: b('yellow')!,
brightBlue: b('blue')!,
brightMagenta: b('magenta')!,
brightCyan: b('cyan')!,
brightWhite: b('white')!,
};
}
/** Ghostty's config: flat `key = value`, ANSI palette as sixteen `palette = N=#hex` lines. */
export function parseGhosttyConf(text: string): AnsiSource | undefined {
let background: string | undefined;
let foreground: string | undefined;
const palette: Record<number, string> = {};
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (line.startsWith('#')) continue;
const bg = line.match(/^background\s*=\s*(#[0-9a-fA-F]{3,8})/);
if (bg) background = normalizeHex(bg[1]!);
const fg = line.match(/^foreground\s*=\s*(#[0-9a-fA-F]{3,8})/);
if (fg) foreground = normalizeHex(fg[1]!);
const pal = line.match(/^palette\s*=\s*(\d+)\s*=\s*(#[0-9a-fA-F]{3,8})/);
if (pal) {
const hex = normalizeHex(pal[2]!);
if (hex) palette[Number(pal[1])] = hex;
}
}
if (!background || !foreground) return undefined;
for (let i = 0; i <= 15; i++) if (!palette[i]) return undefined;
return {
background,
foreground,
black: palette[0]!,
red: palette[1]!,
green: palette[2]!,
yellow: palette[3]!,
blue: palette[4]!,
magenta: palette[5]!,
cyan: palette[6]!,
white: palette[7]!,
brightBlack: palette[8]!,
brightRed: palette[9]!,
brightGreen: palette[10]!,
brightYellow: palette[11]!,
brightBlue: palette[12]!,
brightMagenta: palette[13]!,
brightCyan: palette[14]!,
brightWhite: palette[15]!,
};
}
/**
* A third shape some community repos name `colors.toml` too, unrelated to Omarchy 4's own — flat
* `color0`..`color15` in standard ANSI order, plus `background`/`foreground`. Try this only after
* `paletteFromRecord` (the real Omarchy schema) has already failed on the same file: the two are
* distinguished only by which keys are present, and a file can't be both.
*/
export function parseColor16Toml(t: Record<string, string>): AnsiSource | undefined {
const at = (i: number): string | undefined => (t[`color${i}`] ? normalizeHex(t[`color${i}`]!) : undefined);
const background = t.background ? normalizeHex(t.background) : undefined;
const foreground = t.foreground ? normalizeHex(t.foreground) : undefined;
if (!background || !foreground) return undefined;
const slots = Array.from({ length: 16 }, (_, i) => at(i));
if (slots.some((s) => !s)) return undefined;
return {
background,
foreground,
black: slots[0]!,
red: slots[1]!,
green: slots[2]!,
yellow: slots[3]!,
blue: slots[4]!,
magenta: slots[5]!,
cyan: slots[6]!,
white: slots[7]!,
brightBlack: slots[8]!,
brightRed: slots[9]!,
brightGreen: slots[10]!,
brightYellow: slots[11]!,
brightBlue: slots[12]!,
brightMagenta: slots[13]!,
brightCyan: slots[14]!,
brightWhite: slots[15]!,
};
}
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
// WCAG contrast, so "is this readable" stops being a matter of taste.
//
// Every complaint about the current themes reduces to one of two failures: a highlight so strong it
// masks the content on it, or text so close to its background it disappears. Both are a contrast ratio,
// and a ratio is checkable. This file is what turns the whole exercise from opinion into a test.
export type Rgb = { r: number; g: number; b: number };
export function parseHex(hex: string): Rgb {
const h = hex.trim().replace('#', '');
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
return {
r: Number.parseInt(full.slice(0, 2), 16),
g: Number.parseInt(full.slice(2, 4), 16),
b: Number.parseInt(full.slice(4, 6), 16),
};
}
export const toHex = ({ r, g, b }: Rgb): string =>
`#${[r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`;
/** Relative luminance, per WCAG 2.1. */
export function luminance(c: Rgb): number {
const channel = (v: number) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
}
/** 1 (identical) to 21 (black on white). */
export function contrast(a: string, b: string): number {
const [x, y] = [luminance(parseHex(a)), luminance(parseHex(b))];
const [hi, lo] = x > y ? [x, y] : [y, x];
return (hi + 0.05) / (lo + 0.05);
}
/**
* The thresholds, and why these ones.
*
* `TEXT` is WCAG AA for body text. `UI` is AA for large text and non-text contrast — borders, icons, the
* edge of a control. A border at 4.5 would be a hard line around everything, which is a different design
* rather than a more accessible one.
*/
export const TEXT = 4.5;
export const UI = 3;
/**
* Push a foreground away from its background until it is readable.
*
* Moves toward black or white — whichever the background is further from — in small steps, so a colour
* that is nearly readable keeps almost all of its hue and only a hopeless one ends up monochrome. That
* matters: the point is a theme that still looks like Nord, not one that passes and looks like nothing.
*
* Returns the original when it already passes, so a well-chosen palette is untouched.
*/
export function ensureContrast(fg: string, bg: string, min = TEXT): string {
if (contrast(fg, bg) >= min) return fg;
// Whichever extreme is FURTHER from the background, not whichever side of mid-grey it falls on.
// Those differ for a mid-tone fill and the difference is the whole answer: rose-pine's rose is at
// luminance 0.25, so a `> 0.5` test sends this toward white and caps at 3.5:1, while black reaches
// 6:1 easily. Six of its tones were unfixable until this stopped guessing and measured.
const black = '#000000';
const white = '#ffffff';
const target = parseHex(contrast(black, bg) >= contrast(white, bg) ? black : white);
const start = parseHex(fg);
// 40 steps of 2.5% each. Fine enough that the first passing value is close to the original.
for (let i = 1; i <= 40; i++) {
const t = i / 40;
const moved = toHex({
r: start.r + (target.r - start.r) * t,
g: start.g + (target.g - start.g) * t,
b: start.b + (target.b - start.b) * t,
});
if (contrast(moved, bg) >= min) return moved;
}
return toHex(target);
}
/** Blend two colours. `amount` is how much of `b`. */
export function mix(a: string, b: string, amount: number): string {
const [x, y] = [parseHex(a), parseHex(b)];
return toHex({
r: x.r + (y.r - x.r) * amount,
g: x.g + (y.g - x.g) * amount,
b: x.b + (y.b - x.b) * amount,
});
}
/** `#rrggbb` → `r g b` for `rgb(var(--x) / 0.5)`, which is how the surfaces get their opacity. */
export const rgbTriplet = (hex: string): string => {
const { r, g, b } = parseHex(hex);
return `${r} ${g} ${b}`;
};
+76
View File
@@ -0,0 +1,76 @@
import type { Palette } from './palette';
import type { AnsiSource } from './ansi-source';
import { UI, contrast, ensureContrast, luminance, mix, parseHex } from './contrast';
// An `AnsiSource` (16 ANSI slots + primary bg/fg) has no notion of "three background depths" or "an
// accent" — Omarchy 4's `colors.toml` is a richer, hand-curated shape that a sixteen-slot terminal scheme
// was never designed to fill. This derives one anyway, by role rather than by guessing a fixed value:
//
// - accent is whichever blue-ish slot actually stands out from the background (every official palette
// picks blue too — this just guards against a scheme that reused the background for it).
// - the three depths are the background pushed a fixed distance toward black or white, in the direction
// each mode's surfaces already move (dark themes rise lighter, light themes recede darker — see the
// ratios comment below, calibrated against Nord and Rosé Pine's real colors.toml).
// - the three foreground weights are the foreground pulled toward the background by a fixed fraction,
// standing in for "faint", "muted" and "strong" text.
//
// None of this has to be exact. `mapPalette`'s `ensureContrast` re-checks every text/surface pair
// afterward and nudges anything that fails — the approximation only has to be plausible, not correct.
function pickAccent(a: AnsiSource): string {
const candidates = [a.blue, a.brightBlue, a.cyan, a.brightCyan];
const best = candidates.reduce((hi, c) => (contrast(c, a.background) > contrast(hi, a.background) ? c : hi));
// `--ring` is this colour laid directly on `--background` (see `map.ts`), and it's in the contract's
// PAIRS check same as the official themes — so guarantee it the way `ensureContrast` guarantees text,
// rather than only guarding against a background-coloured accent.
return ensureContrast(best, a.background, UI);
}
export function deriveOmarchyPalette(a: AnsiSource): Palette {
const dark = luminance(parseHex(a.background)) < 0.5;
const mode: Palette['mode'] = dark ? 'dark' : 'light';
const darkerBackground = dark ? mix(a.background, '#000000', 0.45) : mix(a.background, '#000000', 0.1);
const darkBackground = dark ? mix(a.background, '#000000', 0.22) : mix(a.background, '#000000', 0.06);
const lighterBackground = dark ? mix(a.background, '#ffffff', 0.07) : mix(a.background, '#000000', 0.035);
// Faintest → strongest, all pulled toward the background rather than toward a fixed grey, so a warm
// palette's dim text stays warm instead of turning neutral.
const darkForeground = mix(a.foreground, a.background, 0.55);
const lightForeground = mix(a.foreground, a.background, 0.3);
const accent = pickAccent(a);
const muted = contrast(a.brightBlack, a.background) >= 1.15 ? a.brightBlack : mix(a.foreground, a.background, 0.6);
return {
mode,
accent,
// Unused by `mapPalette` today (grep confirms `p.selection` has no reader) — kept only because the
// `Palette` type requires it.
selection: mix(a.background, a.foreground, 0.2),
muted,
background: a.background,
darkBackground,
darkerBackground,
lighterBackground,
foreground: a.foreground,
darkForeground,
lightForeground,
brightForeground: a.foreground,
red: a.red,
yellow: a.yellow,
green: a.green,
cyan: a.cyan,
blue: a.blue,
magenta: a.magenta,
// Standard ANSI has no orange slot. Mixing red and yellow reads better as "busy" than falling back to
// plain yellow the way `loadPalette` does for the 3 official themes that omit it.
orange: mix(a.red, a.yellow, 0.5),
brightRed: a.brightRed,
brightYellow: a.brightYellow,
brightGreen: a.brightGreen,
brightCyan: a.brightCyan,
brightBlue: a.brightBlue,
brightMagenta: a.brightMagenta,
};
}
+200
View File
@@ -0,0 +1,200 @@
import type { Palette } from './palette';
import { UI, contrast, ensureContrast, mix, rgbTriplet } from './contrast';
// Omarchy palette → Officer's 84 declarations.
//
// This file is the whole port. It exists once so that twenty-two themes cannot disagree about what
// "muted" means, and so that improving the mapping improves all of them at once.
//
// ── What went wrong before, and what this fixes ──
//
// The previous port produced 34 declarations per theme against a contract of 84. The missing fifty were
// every surface (`--panel-bg`, `--card-bg`, `--glass-bg`), every state colour (`--success`, `--warning`,
// `--busy`, `--info`), the header, the scrollbars and the grid — so they fell back to whatever the base
// stylesheet or the previous theme left behind. That is why highlights masked content and text faded
// out: not taste, absence. It also shipped thirteen tokens nothing reads (`--chart-1..5`, `--sidebar-*`),
// which is the tell that it came from a shadcn template rather than from this codebase's contract.
//
// ── The rule that makes them readable ──
//
// Every foreground is run through `ensureContrast` against the surface it actually sits on. A palette
// whose colours are already readable is untouched; one that is not gets nudged toward black or white
// until it passes, keeping as much hue as it can. So a theme cannot ship with invisible text, no matter
// what its author chose.
export type Tokens = Record<string, string>;
/** Depth ordering differs by mode: a light theme's "darker" is its raised surface, not its page. */
function surfaces(p: Palette) {
const dark = p.mode === 'dark';
return {
/** The page. The deepest surface in dark, the plainest in light. */
page: dark ? p.darkerBackground : p.background,
/** Panels and cards sit one step up from the page, so a tile reads as a tile. */
raised: dark ? p.background : p.lighterBackground,
/** Hovers, secondary fills, the header. One step up again. */
high: dark ? p.lighterBackground : p.darkBackground,
dark,
};
}
export function mapPalette(p: Palette): Tokens {
const s = surfaces(p);
// Text, checked against the surface each weight actually appears on.
const fg = ensureContrast(p.foreground, s.page);
const fgRaised = ensureContrast(p.foreground, s.raised);
// Checked against `s.high`, not `s.raised`: `--muted-foreground` is the text ON `--muted`, and
// `--muted` is the high surface. Deriving it against the wrong one left everforest at 4.44:1 and
// miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a
// person eyeballing a screenshot would have shipped.
const fgMuted = ensureContrast(p.lightForeground, s.high);
// Same reasoning as `fgMuted`, for the same surface: `--accent` and `--secondary` are BOTH `s.high`,
// so their text has to be checked against `s.high` too. It was deriving from `fgRaised` — the weight
// for `s.raised` — which is the identical near-miss the comment above describes, just one token over.
// A no-op for all 22 official palettes (each already clears 4.5:1 on `s.high` by 1.4x or more, and
// `ensureContrast` returns its input untouched when it passes); what it fixes is the ported palettes
// whose foreground sits between the two surfaces' thresholds.
const fgHigh = ensureContrast(p.foreground, s.high);
const fgFaint = ensureContrast(p.darkForeground, s.raised, UI);
/**
* Text for a solid fill of `bg`.
*
* Tries the palette's own extremes first, so a button's label still belongs to the theme, and only
* falls through to `ensureContrast` when neither is readable. Both steps are needed: picking the
* better of two candidates does NOT guarantee either passes — rose-pine's fills are all mid-tone and
* every one came out between 2.2:1 and 4.2:1 until this drove it the rest of the way.
*/
const on = (bg: string): string => {
const light = p.brightForeground;
const dark = p.darkerBackground;
const better = contrast(light, bg) >= contrast(dark, bg) ? light : dark;
return ensureContrast(better, bg);
};
const accent = p.accent;
// Borders are DECORATION, not information, so they are not held to a text ratio — a 3:1 line drawn
// around every panel is a different design, not a more accessible one. What they must be is
// discernible, which `--input` below is checked for: a form field whose boundary you cannot see is a
// real accessibility failure, unlike a subtle panel edge.
const border = mix(s.raised, s.dark ? p.lightForeground : p.darkForeground, 0.28);
return {
// ── Ground ──
'--background': s.page,
'--foreground': fg,
// ── Surfaces ──
'--card': s.raised,
'--card-foreground': fgRaised,
'--popover': s.raised,
'--popover-foreground': fgRaised,
// `muted` is a SURFACE here, not the palette's `muted` colour — that one is a mid-grey meant for
// dimmed text, and using it as a fill is precisely how a highlight ends up masking its content.
'--muted': s.high,
'--muted-foreground': fgMuted,
'--accent': s.high,
'--accent-foreground': fgHigh,
// ── Actions ──
'--primary': accent,
'--primary-foreground': on(accent),
'--secondary': s.high,
'--secondary-foreground': fgHigh,
'--cta': accent,
'--cta-foreground': on(accent),
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
// ── State — five tones, each readable on its own fill ──
'--success': p.green,
'--success-foreground': on(p.green),
'--warning': p.yellow,
'--warning-foreground': on(p.yellow),
'--destructive': p.red,
'--destructive-foreground': on(p.red),
'--info': p.blue,
'--info-foreground': on(p.blue),
'--busy': p.orange,
'--busy-foreground': on(p.orange),
// The odd one, per the contract: a fill bright enough for black text is too light to BE text. So ink
// is the same hue pushed until it reads on the page.
'--busy-ink': ensureContrast(p.orange, s.page),
// ── Lines ──
'--border': border,
'--input': border,
'--ring': accent,
'--radius': '0.625rem',
// ── Data ──
'--chart-primary': accent,
'--chart-secondary': p.magenta,
'--status-success': p.green,
'--status-warning': p.yellow,
'--status-in-progress': p.blue,
// ── Surfaces, as complete CSS values ──
'--page-bg': s.page,
'--page-bg-src': "''",
'--panel-bg': `rgb(${rgbTriplet(s.raised)} / 0.72)`,
'--panel-border': `rgb(${rgbTriplet(border)} / 0.9)`,
'--panel-border-bare': `rgb(${rgbTriplet(border)} / 0.55)`,
// DENSER, not darker — panel chrome is dark text and a heavy fill hides every icon. The contract
// says so; this is the one place a value is chosen to obey a sentence rather than a ratio.
'--panel-raised-bg': `rgb(${rgbTriplet(s.raised)} / 0.95)`,
'--panel-raised-border': `rgb(${rgbTriplet(border)} / 1)`,
'--panel-accent': accent,
'--panel-header-bg': `rgb(${rgbTriplet(s.high)} / 0.6)`,
'--panel-header-fg': fgRaised,
'--panel-radius': '0.75rem',
'--panel-blur': '12px',
'--card-bg': s.raised,
'--glass-bg': `rgb(${rgbTriplet(s.raised)} / 0.55)`,
'--glass-border': `rgb(${rgbTriplet(border)} / 0.7)`,
'--dock-bg': `rgb(${rgbTriplet(s.raised)} / 0.7)`,
'--dock-border': `rgb(${rgbTriplet(border)} / 0.8)`,
'--dock-tooltip-bg': s.high,
'--header-bg': `rgb(${rgbTriplet(s.raised)} / 0.8)`,
'--header-border': `rgb(${rgbTriplet(border)} / 0.9)`,
'--header-fg': fgRaised,
'--header-blur': '12px',
// Grid lines are a texture, not a colour anyone should notice — kept just above the surface.
'--pixel-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.07)`,
'--card-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.05)`,
'--panel-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.06)`,
'--scrollbar-thumb': `rgb(${rgbTriplet(p.muted)} / 0.6)`,
'--scrollbar-thumb-hover': `rgb(${rgbTriplet(p.muted)} / 0.85)`,
'--scrollbar-thin-thumb': `rgb(${rgbTriplet(p.muted)} / 0.45)`,
'--scrollbar-thin-thumb-hover': `rgb(${rgbTriplet(p.muted)} / 0.7)`,
// ── Brand slots ──
//
// A ported theme has no brand of its own, so these follow its accent rather than importing Duck's
// green into somebody else's palette. `--ink` is the app's text colour and is used ~950 times.
'--brand': accent,
'--brand-foreground': on(accent),
'--brand-primary': accent,
'--brand-accent': p.cyan,
'--brand-warm': p.orange,
'--brand-deep': s.dark ? p.darkerBackground : p.darkBackground,
'--brand-muted': p.muted,
'--ink': fg,
'--surface-warm': s.raised,
'--naturegreen': p.green,
'--naturegreen-foreground': on(p.green),
// Legacy aliases the contract keeps until the utility rename lands — `prose.css` and some plugins
// read them directly, so a theme without them has unstyled prose.
'--duck-dark': fg,
'--duck-teal': p.cyan,
'--duck-yellow': p.yellow,
'--duck-orange': p.orange,
'--duck-forest': p.green,
'--duck-beige': mix(s.raised, fg, 0.1),
'--logo-src': "'/officer-logo.svg'",
};
}
+97
View File
@@ -0,0 +1,97 @@
import { readFileSync } from 'node:fs';
// Reading an Omarchy theme's `colors.toml`.
//
// Omarchy 4 ships a genuinely semantic palette rather than sixteen ANSI slots: three background depths,
// four foreground weights, an accent, a selection and a muted, plus the named hues. That is why this port
// can be a mapping at all — the previous one had to guess, and the guessing is what produced themes with
// no surfaces and unreadable highlights.
//
// Verified across all 22 themes in basecamp/omarchy@master: every one declares the 24 keys below.
export type Palette = {
mode: 'dark' | 'light';
accent: string;
selection: string;
muted: string;
background: string;
darkBackground: string;
darkerBackground: string;
lighterBackground: string;
foreground: string;
darkForeground: string;
lightForeground: string;
brightForeground: string;
red: string;
yellow: string;
green: string;
cyan: string;
blue: string;
magenta: string;
/** Not in every theme — 19 of 22. Falls back to yellow-red mid, which is what orange is. */
orange: string;
brightRed: string;
brightYellow: string;
brightGreen: string;
brightCyan: string;
brightBlue: string;
brightMagenta: string;
};
/**
* A deliberately small TOML reader: this file is flat `key = "value"` and nothing else.
*
* Exported (not just used by `loadPalette` below) because a community theme can ship this exact shape
* too — see `port-community-themes.ts`, which reads it from a fetched string rather than a file path.
*/
export function parseTomlText(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split('\n')) {
const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i);
// basecamp/omarchy's own files are already lowercase hex; a community `colors.toml` isn't guaranteed
// to be, so normalize here rather than leaving the two sources visibly inconsistent.
if (m) out[m[1]!] = m[2]!.toLowerCase();
}
return out;
}
/** The 24-key contract, given an already-parsed record rather than a path — see `parseTomlText`. */
export function paletteFromRecord(t: Record<string, string>, label: string): Palette {
const need = (key: string): string => {
const v = t[key];
if (!v) throw new Error(`${label}: missing "${key}"`);
return v;
};
return {
mode: t.mode === 'light' ? 'light' : 'dark',
accent: need('accent'),
selection: need('selection'),
muted: need('muted'),
background: need('background'),
darkBackground: need('dark_background'),
darkerBackground: need('darker_background'),
lighterBackground: need('lighter_background'),
foreground: need('foreground'),
darkForeground: need('dark_foreground'),
lightForeground: need('light_foreground'),
brightForeground: need('bright_foreground'),
red: need('red'),
yellow: need('yellow'),
green: need('green'),
cyan: need('cyan'),
blue: need('blue'),
magenta: need('magenta'),
orange: t.orange ?? need('yellow'),
brightRed: need('bright_red'),
brightYellow: need('bright_yellow'),
brightGreen: need('bright_green'),
brightCyan: need('bright_cyan'),
brightBlue: need('bright_blue'),
brightMagenta: need('bright_magenta'),
};
}
export function loadPalette(tomlPath: string): Palette {
return paletteFromRecord(parseTomlText(readFileSync(tomlPath, 'utf8')), tomlPath);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Regenerate `TODO.md` — the index over `todo/`.
*
* The index is the one file everybody reads, so it is the one most able to lie. It is generated rather
* than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree
* it describes.
*/
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname;
const OUT = join(ROOT, 'todo');
export type Item = {
id: string;
title: string;
theme: string;
status: string;
created: string;
/** `urgent` or empty — see todo/README.md for why there is no scale. */
priority: string;
due: string;
order: number;
file: string;
};
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
export function frontmatter(text: string): Record<string, string> {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return {};
const out: Record<string, string> = {};
for (const line of text.slice(4, end).split('\n')) {
const m = line.match(/^([a-z_]+):\s*(.*)$/);
if (!m || !m[1]) continue;
const raw = (m[2] ?? '').trim();
out[m[1]] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
}
return out;
}
export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } {
const themes = readdirSync(OUT)
.filter((d) => statSync(join(OUT, d)).isDirectory())
.sort()
.map((theme) => {
const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md'));
const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8'));
const items = files
.filter((f) => f !== '_index.md')
.map((f) => {
const fm = frontmatter(readFileSync(join(OUT, theme, f), 'utf8'));
return {
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
title: fm.title ?? f,
theme,
status: fm.status ?? 'open',
created: fm.created ?? '',
priority: fm.priority ?? '',
due: fm.due ?? '',
order: Number(fm.order ?? 0),
file: `todo/${theme}/${f}`,
};
})
.sort((a, b) => a.order - b.order);
return { theme, heading: meta.heading ?? theme, items };
});
return { themes };
}
const HEADER = `# TODO — the index
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
Format and the two-way-sync contract: \`todo/README.md\`.
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
`;
function render(): string {
const { themes } = readTree();
const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0);
const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0);
const rows = themes
.filter((t) => t.items.length)
.map((t) => {
const o = t.items.filter((i) => i.status !== 'done').length;
const d = t.items.length - o;
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`;
});
const recent = themes
.flatMap((t) => t.items)
.filter((i) => i.status !== 'done')
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, 10)
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode this
// whole split risks. List them explicitly rather than letting them age out of view.
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
// Urgent goes above everything. An item the owner called urgent, sorted in among seventy-five others
// by theme, is an item nobody sees on the day it matters.
const urgent = themes
.flatMap((t) => t.items)
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
return [
HEADER,
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
`## Themes\n`,
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
`| Theme | Open | Closed |`,
`| --- | --- | --- |`,
...rows,
``,
`## Records — no items, kept for the reasoning\n`,
...notes,
``,
`## Newest ten\n`,
...recent,
``,
].join('\n');
}
if (import.meta.main) {
writeFileSync(join(ROOT, 'TODO.md'), render());
console.log('TODO.md regenerated');
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bun
import { snapshotPlugins } from '../src/servers/plugins/mount';
import { updatePlugin, syncCapabilities } from '../src/servers/plugins/update';
// Update every installed plugin, from a terminal.
//
// bun run plugins:update
//
// ── Why this exists ──
//
// The only way to do this was `POST /api/plugins/update-all`, which is authenticated — so a deploy had to
// mint an API key to update the plugins on the machine it was already logged into. That is a credential
// created, used and hopefully deleted, on every host, for a local operation.
//
// This is the same work through the same functions, with the HTTP layer removed rather than a second
// implementation beside it. `prompts/deploy-commander.md` calls it.
//
// ── It runs OUTSIDE the server process ──
//
// Which matters, and is the one thing to know before reading the output. `updatePlugin` pulls, re-applies
// and rebuilds the frontend — all of which touch disk and are picked up by the running server. But
// remounting happens in the process that calls it, and this is not that process, so the routes the
// server is serving do not change until it restarts.
//
// So: the files, the schema, the assets and the SPA bundle are all current when this finishes. The route
// table is not. A deploy restarts `officer` afterwards, which is what makes the difference invisible —
// and the reason this says so rather than leaving somebody to find out.
const started = Date.now();
const { states } = await snapshotPlugins();
const installed = states.filter((st) => st.install).map((st) => st.plugin.appName);
if (installed.length === 0) {
console.log('no plugins installed — nothing to update');
process.exit(0);
}
console.log(`updating ${installed.length} plugin(s): ${installed.join(', ')}\n`);
let failed = 0;
// SEQUENTIAL, like the route: each update pushes a schema, republishes assets, rewrites the ecosystem
// file and rebuilds the frontend. Two at once is a race over one file each.
for (const appName of installed) {
process.stdout.write(`→ ${appName}\n`);
// One plugin failing must not stop the rest. A dirty checkout is the EXPECTED failure — every plugin
// directory on a developer machine is a live one — and it should not hold back the other eleven.
const result = await updatePlugin(appName, async (step) => console.log(` ${step}`));
if (!result.ok) {
failed++;
console.error(` FAILED: ${result.error}`);
}
}
// The capabilities repository rides along, the same as it does through the button: it is one question —
// "get me what has been published since I installed" — and a second gesture for it is one nobody remembers.
const capabilities = await syncCapabilities();
console.log(`\ncapabilities: ${capabilities.action}${capabilities.error ? ` — ${capabilities.error}` : ''}`);
const ok = installed.length - failed;
console.log(`${ok}/${installed.length} updated in ${Math.round((Date.now() - started) / 1000)}s`);
if (failed) console.log("a failure above is usually an uncommitted change in that plugin's checkout");
// Non-zero on failure, so a deploy script can stop rather than reporting a success it did not have.
process.exit(failed > 0 ? 1 : 0);

Some files were not shown because too many files have changed in this diff Show More