Commit Graph
88 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
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 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
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
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
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 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 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 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 0f93262a8c usage panel: verified for one account, not for two
the owner confirmed it live the same day. what that leaves untested is the
half the cache exists for — a second login, the tabs, a stored row surviving
a switch — so the docs now say which half is proven instead of claiming
neither is.

also records the bug the first deploy found, since the doc had asserted the
degradation that turned out to be broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:12:47 +00:00
pastilhasandClaude Opus 5 19f558077e document the usage panel and what testing it would mean
it has never run: db:push has not been applied and neither process has been
restarted. docs/ai-usage-panel.md records where every number comes from and
why officer cannot derive any of them itself, the two things the live
payload settled (every model-scoped bucket is null while limits[] carries a
real fable window; the window is named by kind, never by the model name),
the measured costs, and the staleness rules — which are the part most likely
to be wrong, because they assert things about data they did not measure.

sixteen ordered tests in five groups. group C is the actual feature. test 10
is the one that would break silently: a refresh must not overwrite an
account it could not measure.

also records what the scan measurement found, since it decides the deferred
work — 74.7% of message ids appear in more than one transcript file, so
per-file aggregates cannot be summed and an incremental scan is not the
one-liner it looks like.

TODO.md gains an entry under "waiting on the owner to run it", beside plan
mode, which is also still unexercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:55:30 +00:00
pastilhas d88bf7b5f5 todo: what has to be true before the plugin ecosystem opens 2026-08-18 02:46:40 +00:00
pastilhas e350c798bb todo: the three plugin-system gaps the email extraction exposed 2026-08-18 01:05:31 +00:00
pastilhasandClaude Opus 5 b63badaf67 remove the email residue the extraction left in the platform
a sweep for remnants after 2026-08-18. nothing was broken - no dangling
import, symbol, route or permission key anywhere in src/ - but the platform
was still speaking the plugin's vocabulary in a dozen places.

the one that mattered is live code rather than prose: ChatPanelWrapper
hardcodes workspace.id === 'email' to send context: 'email', which is the
frontend half of the pair whose backend half (resolveEmailCwd) carries a
fifteen-line justification. the two ends now both say so, including that
adding a SECOND id there is the signal the platform needs a real way for a
plugin to declare a chat context.

the rest: two dead imports left by removing the settings section, an empty
'Email' type-section header, a protocol.ts section header with no members
naming the plugin's SSE design, and a queue comment pointing at
sidecar/email/sync-runner.ts - a path in another repository now.

CLAUDE.md, AGENTS.md and working-on-officer.md described email as a platform
feature, an app permission and a pm2 process. those are the files a fresh
agent reads first, so they were the highest-risk staleness in the tree.

TODO.md's whole Email section moved to the plugin's own repository. it held
OPEN items pointing at sidecar/email/store.ts, which is exactly how a leftover
list sends someone to edit files that do not exist.

the delete-account dialog promised it would remove email accounts and
playlists. both tables belong to plugins now, so that cascade only happens
where the plugin is installed - it now says 'anything an installed plugin
keeps for them' instead of naming two it cannot guarantee.

imapflow and mailparser STAY in package.json. the plugin has no package.json
of its own and resolves from the platform's node_modules, so removing them
would break the installed plugin's sidecar. the fix is giving plugins their
own dependency manifest, not deleting these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 01:01:23 +00:00
pastilhasandClaude Opus 5 43fb2952fa a test plan for plan mode, for a day that is not today
the owner has no time to test any of it, so the checklist has to work for
someone with none of today's context: eleven ordered tests, what each should
show, and what its failure looks like.

test 4 is called out as the one most likely to be wrong - approving
ExitPlanMode drops the harness into default rather than back into
bypassPermissions, which is the finding that changed the design and therefore
the thing the implementation is most likely to have got wrong.

also separates what was verified from what was not: the probe runs against a
real claude, the turn-stream unit tests, and then everything end to end, which
is nothing.

the inline 'what to watch' list in chat-plan-mode.md is replaced by a pointer
so there is one source of truth, and TODO.md now says live-but-unexercised
rather than built-not-run - the restarts are done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:43:27 +00:00
pastilhasandClaude Opus 5 004f702747 no rotation needed: nobody else has ever used this install
the owner confirmed the additional accounts are test rows - none has ever
signed in, no member has opened a shell or a chat. so the proxy secret was
readable by accounts that do not exist in practice, and there is nothing to
rotate.

the mode fix stands: it is about the install that gets used, not this one.
dropping the TODO and recording the reasoning instead, because a rotation item
left sitting there reads as outstanding work forever, and the next person to
find a world-readable secret should ask who uses the box before reaching for
the alarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:24:05 +00:00