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>
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>
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>
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>
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>
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>
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>
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>
`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.
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.
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>
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.
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.
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>
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>
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>
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>
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>
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 , 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>
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>
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>
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>
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>
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>
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>
data/sidecar/claude-state.json holds proxySecret, and that string IS the
owner's Claude Max subscription: officer-anthropic-proxy accepts it as
x-api-key, swaps it for the owner's OAuth bearer and forwards to
api.anthropic.com. it listens on 127.0.0.1, which every local account can
reach.
it was 664 in a 775 directory. nothing had ever set a mode - mkdirSync and
Bun.write at ubuntu's default umask of 002. confirmed by reading the live
secret as 'nobody', an account with no relationship to the platform, and by
confirming the proxy 401s without the key so the secret is the only control.
this does NOT mean members shared the subscription. spawnClaudeAsMember runs
their claude through setpriv --reset-env with a six-name allowlist, so
ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY cannot cross into it and their turns
go straight to anthropic on their own OAuth. that design is intact - it could
just be walked around by anyone with a shell.
fixed twice on purpose. state.ts sets 0600/0700 on every write, which is the
authoritative fix, and on every write rather than at creation because
writeFileSync leaves an existing file's mode alone. secureRuntimeSecrets()
sweeps at boot because officer-anthropic-proxy is a separate pm2 process that
can recreate the file hours after officer started, and because every existing
install has the old mode until something repairs it.
it repairs rather than refusing to boot, unlike assertSecretsClosed beside it.
the line is who wrote the file: .env is the installer's and we must not guess
at it; this is our own output at the machine's umask, so refusing to start
would punish the user for our bug.
the per-account copies are swept too. no live secret in them today, but they
map session keys to transcript uuids for named accounts.
tests verified to bite: removing the chmod while still reporting success fails
four of them.
a chmod does not undo exposure - the secret still needs rotating on the
production host. TODO.md has the sequence, including that officer-claude-code
caches it and must be restarted alongside the proxy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This bug will not be reproduced on demand. It will arrive as somebody's report,
hours later, from a browser whose console is long gone — so a `console.warn` is
seen by nobody, and "what did you see?" gets an answer built from memory and a
screenshot.
So the trap now persists. Up to five occurrences in
`localStorage['officer:editor-buffer-trap']`, each carrying the stack at the moment
of the edit, the first 200 characters that arrived, how many, which file, and when.
The question becomes "paste this key", and the answer is complete.
Recording a bug must never become one: private mode, a full quota and a disabled
storage API all throw at that write, and none of them is worth breaking an edit
over. The console line survives either way.
`docs/editor-buffer-corruption.md` is the procedure — what it looks like, the four
candidates already eliminated and why, the one command to ask a reporter for, how
to read each field, what an EMPTY key would mean (either the editor was focused,
making it a paste, or the insertion came in chunks under the threshold), and the
instruction to delete every piece of this once the cause is known. Linked from
TODO.md and from CLAUDE.md's reading list, so somebody handed a report finds it
without grepping for it.
Stated there rather than left implicit: a fix invented without knowing the cause
would hide the symptom on the one screen where it is visible while the mechanism
carried on doing whatever else it does. The corruption is never saved and reopening
the tab clears it, so waiting is cheap and a wrong fix is not.
tsgo clean, frontend builds, 873 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not a fix — I could not find the cause by reading, and shipping a guess dressed as
one is worse than saying so.
What reading DID settle, and it removes two of the three candidates:
- The end-of-turn refresh cannot be it. `reloadFile` sets `originalContent` and
clears `isDirty`, so no refresh can produce the dirty dot in the screenshot.
- Nothing in the app types into the page: no `execCommand('insertText')`, no
synthetic keyboard events, no clipboard writes anywhere near the editor.
That leaves Monaco's own change event, which means the model was edited the way a
paste or a programmatic `executeEdits` edits it. Neither is identifiable from a
screenshot taken afterwards, which is why this needs to be caught in the act.
`reportSuspiciousEdit` logs any insertion over 200 characters arriving while the
editor does NOT have focus, with an Error constructed at that moment so the stack
names the caller. A person cannot type 200 characters into an editor they are not
focused on; whatever does will be in that trace.
It prevents nothing and changes no behaviour. Delete it once the cause is known.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The platform's own reference to offscale was one stale comment naming /headscale
as an example route pair. Everything else that matches "offscale" in src/ is a
test fixture using it as an example app name, or the bundled catalogue entry.
Re-synced the marketplace so the catalogue advertises the ssh dependency the
manifest now declares — which is the point of the sync being cheap to re-run: a
plugin's manifest changing is not a marketplace deployment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The site is up on HTTPS with a Let's Encrypt certificate, proxied to :9100, and
serving the three plugins out of `officer_marketplace`. An Officer install with no
configuration now reads its catalogue from it.
── What is running ──
PM2 entry added to THIS machine's ecosystem only. Deliberately not in
officer-setup's CORE_PROCESSES: the marketplace is one website officer.dev hosts,
not something every install should run — a copy on every machine would serve a
stale catalogue on a port nobody asked to open.
nginx (NPM host id 7) → 172.20.0.1:9100, with a ufw rule for the docker bridge.
That rule is not optional and its absence does not look like a firewall problem:
without it nginx HANGS until proxy_connect_timeout rather than being refused,
which is how the apptest hosts wasted an hour a few days ago.
── READMEs ──
`music`, `offscale` and `example` now have proper READMEs, written against their
own code and pushed to their own repos. They render on the plugin's page in the
store and are what a self-hoster reads before installing. The example's is a
tutorial as much as a shop window — it is the document a plugin author reads
first, and every convention in it was verified in `discover.ts` rather than
restated from memory.
`music`'s remote was HTTPS with no credentials on this box, so it could not be
pushed; switched to SSH like the other two. The catalogue still advertises HTTPS —
`marketplace-sync` converts — which is the property that matters, since an
`ssh://` clone URL only works on the machine that published it.
── Writing them was the cheapest audit available ──
Reading each plugin against its own documentation turned up ~20 real defects, all
recorded in TODO.md and none fixed here. The pattern worth naming: offscale is
still branded "headscale" in user-visible panel names and in a persisted terminal
session id, and its PLUGIN.md states outright that the ACL assistant is
deliberately unwired — while `sidecar/assist.ts`, `claude-proxy.ts` and
`PolicyAssistant.tsx` all ship. Documentation that confidently describes the
opposite of the code is worse than none.
Also fixed the docs on the frozen lockfile: both CLAUDE.md and bunfig.toml named
`bun install --no-frozen-lockfile` as the escape hatch, and bun 1.3.14 has no such
flag — it is ignored and the install fails anyway.
tsgo clean, 873 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported on the production host, as a member, with a `bun dev` job streaming: the
runner's output rendered into the buffer of a file open in the code editor. The
file on disk was intact and nothing was saved, so it is a display-layer mixing
rather than a write path.
Not reproduced and not diagnosed — logged with the two places the streams can
meet (`CodeSurface` is shared between the task panel and the editor; the editor
keys by path while panel stores key by panelId through useGlobal/usePanelMemory)
and a first move that distinguishes them: whether the buffer is polluted or only
the render is, which Cmd+S and a tab reopen answer immediately.
Recorded rather than guessed at further. An editor that can show content the file
does not contain is a correctness problem even when it never writes, because the
instinct on seeing it is to save.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The URL model is done; rows are still opaque-click divs, which was the original
finding and is now easier to fix than it was. Recorded honestly rather than
leaving an entry that reads as untouched work.
Also recorded the pattern behind the four bugs this produced: the list of overlay
params has an owner, the acts that clear it do not, and every site deciding for
itself is what let the breadcrumb ship wrong an hour after the rule was written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recorded next to the code-editor entry, since the owner wants both after the
current test pass.
The value is the verified state rather than the request: rows and search results
are opaque-click divs, the breadcrumb is the one surface that already does it
right and is the model to copy, a panel's folder is deliberately not addressable,
and the ephemeral-vs-location param split has no single owner — which is what let
onCloseViewer wipe the location along with the overlay.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>