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.
`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.
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.
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.
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.
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.
`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.
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.
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.
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.
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.
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>
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.
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>
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.
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.
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.
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.
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>
--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>
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.
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.
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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
`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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>