cac58ea044336261c7a258e826e11ecbffcc4265
949
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cac58ea044 |
the git-repo button FETCHES, and the card does the installing
it installed, and using it showed why that was wrong: the button is on the grid, the progress log is in the detail panel — so a URL install ran to completion with nothing on screen and the plugin simply appeared, already installed. no feedback, and no moment to change your mind about running somebody's code. cloning alone is enough to make a plugin real. `/api/plugins` lists what is ON DISK, so a fetched plugin shows up with `installed: false` — Available, exactly like a catalogue entry — and the existing card, its Install button and its streamed progress do the rest. one install path, and it is the one people have already used. so `install-from-url` and its streaming twin are gone, replaced by `fetch-from-url`, which clones and stops. the streaming route existed only to carry progress into a view that could not render it. the outcome is REPORTED, since that was the complaint: "fofos-admin added — click it to install", naming what the URL resolved to. the name is derived rather than typed — appName IS the directory, deciding the route, the table prefix and the process name — so being told what it became is the only way to know which card is yours. it stays on screen until the next attempt rather than being a toast that is gone before you look up. discovery is re-run after the clone: fetchPlugin checks the manifest EXISTS, discovery checks it parses. a directory failing that still renders in the grid as broken, which is honest, but saying so at fetch time reaches the person while they are still looking at the field they pasted into. verified: fetch 200 with the derived name, the plugin listed installed=false, a second fetch refused with "already on disk" rather than clobbering it, and the icon present in the clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
5c6c5b31f8 |
"Install from Git repository", in the marketplace
a button beside the plugin count that becomes a URL field. everything after it is the machinery that was already there: same installPlugin, same step and log frames, same progress panel, same result. STREAMED, because that is what "works exactly like it already does" means here. an install writes an ecosystem entry, starts a process and rebuilds the router; answering once at the end is a spinner that stops, with no way to tell "started the sidecar" from "could not". the frame reader in usePlugins was split out rather than copied — it never cared which URL it read from, and two copies of an incomplete-frame parser is two places to get it subtly wrong. A TWIN ROUTE, not a case inside the verb map. every other verb names an INSTALLED plugin in its path; this one names something the machine has never seen, and its subject arrives in the body. folding it in would mean a route whose `:appName` is ignored for exactly one value of `:verb`. NO NAME FIELD, deliberately. appName IS the directory a plugin sits in — never declared in a manifest — so it decides the route segment, the table prefix and the pm2 process name. a box for it would invite a name that disagrees with the address. it is derived server-side and reported as the FIRST streamed step, which is the moment it can still be cancelled. collapsed until asked for: a permanent URL box at the top of a catalogue reads as the primary way in, and it is the exception — for somebody who already has an address. verified end to end against fofos/fofos-admin: 30 frames, every step from clone to mount, `assets: published to /plugins/fofos-admin/` now that it carries an icon, and the icon served at 256x256 with alpha intact. purged afterwards — officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
d273dc6a14 |
install a plugin from a git URL, with no registry involved
the catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend" had no route. the first real case is a plugin that must never be listed: fofos/fofos-admin is one website's admin panel, useful to exactly one person. MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the URL, refused to overwrite an existing directory, checked for a manifest and removed a failed clone. exactly one line tied it to the marketplace — where the string came from. so this is a source parameter threaded through, and a route. THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is never declared in a manifest — the id cannot disagree with where the code sits. that decision is inherited here rather than remade: the repo name becomes the directory, the route segment, the table prefix and the sidecar suffix. so it is checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone under an unusable name reads as a broken plugin rather than an absent one. the URL is validated identically whether it came from the catalogue or from a person, because the catalogue is not a trust boundary — it is just where a string came from, and the same string reaches the same argv. what differs is who may ask: this route is owner-only, and the owner is choosing what code runs rather than choosing from a list somebody curated. validation is not review, and nothing here reads what it cloned. caught by testing: the source reached installPlugin and stopped there — the fetchPlugin call still had the old two arguments, so a URL install fell back to the catalogue and failed with "no catalogue entry". the guard tests are what surfaced it, because the error named the wrong cause. verified end to end against fofos-admin — cloned, schema applied, mounted, dock pinned — then purged, since officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
fb0cc880ac |
sign in as a user, from user management
the support case this exists for is "it does not work for them and it does for me". reproducing that with a password reset means locking somebody out of their own account in order to look at it, which is worse than the bug. THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and reaches every handler, because user-middleware puts the whole payload on the context. without it an impersonated action is indistinguishable from that person's own — in a log, in a transcript, in anything keyed on a user id — and a support tool that can forge activity as somebody else is a different and much worse thing than a support tool. ONE HOUR, not thirty days. a session lasts a month because a person uses their own account daily; impersonation is a task you finish. an hour is long enough to reproduce a problem and short enough that a forgotten tab stops being a live credential for somebody else's account by lunchtime. IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any token carrying `impersonatedBy`, so impersonating another Super Admin does NOT hand user management back — which would have made this a way to launder owner actions through somebody else's id. stated twice, in the gate and in the handler, because one file away is too far for this one. THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an owner session — a far more dangerous thing to own than this whole feature. the cost is that clearing browser storage strands you as the other person until the hour expires, and signing in again fixes that. the banner is loud and sits ABOVE the header, outside everything a fullscreen panel can hide. the failure it prevents is not a hole, it is forgetting: reading a mailbox or deleting something while believing it is yours. it asks the server who it is rather than decoding the token, so it cannot keep showing a name after the hour has run out. verified end to end against a throwaway account, since this host has one user: impersonate 200 with the target's id and impersonatedBy naming the owner, 60 minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining 403, user management while impersonating 403; one [SECURITY] line per start. the probe account was deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
bf8bfa6859 |
start a commander from a plugin chat, without knowing where anything lives
pointing a commander at a mailbox meant typing its path, which meant a file
browser and an account allowed to walk the disk. only the owner has that — so
"watch my email and tell me when X" was a super-user feature, in a product whose
whole point is that it should not be.
every plugin chat already mounts `ChatHeader` with a `chatContext`, and that
context IS the address: the server turns `{context:'email', contextId:'me@…'}`
into the mailbox's own directory, beside its `emails.db`. so one button on that
header reaches every plugin chat there will ever be, and a new plugin gets it by
declaring the context it already had to declare to have a chat at all. that is
what makes this common to all those chats rather than copied into each.
THE CLIENT SENDS NO PATH, because it has none. a plugin's chats live under
$DATA_PATH/<email>/plugins/<app>/chats and an email chat beside its own database;
both are built server-side and deliberately never sent to the browser. the
resolver moved out of chat.ts into `chat-context.ts` so command-hq uses the SAME
mapping — two copies would be two chances for a commander to watch a directory
its own chat has never been in.
it NAVIGATES rather than creating on the spot. a commander with no first turn has
no transcript, and the grid filters those rows out — so "create it silently"
makes something the person cannot then find. the setup screen is also where the
first turn happens, which is where the job actually gets settled. the button
carries the context there, pre-fills the name, and leaves one button to press.
the reference is shown as what it WATCHES rather than as a path, because there is
no path to show.
caught by testing rather than by reading: `resolveChatContextDir` reads the query
string, and command-hq takes its context in a JSON body — so the first version
resolved every commander to the owner's home. that failure is invisible, since a
commander watching the wrong directory just answers about the wrong thing
forever. the value-taking `chatContextDir` is what command-hq calls.
verified live: context 'email' resolves to email_accounts, plugin/proxy-manager
to its own chats directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
9ec60d6d75 | caldav 2026.08.27.03 — filters on calendar and contacts | ||
|
|
02f20d419d |
an opencode chat you can reopen: it has a name, its messages, and its model
three complaints, one root cause and two small ones. THE TRANSCRIPT CAME BACK EMPTY because the client read `/session/<id>/message` and this build answers that with `[]` — 200, well-formed, and wrong. the same session returns 4 messages on `/api/session/<id>/message`. an empty transcript is indistinguishable from a new conversation, so reopening a chat said "send a message to start" and every message the owner had sent looked deleted. that also explains the third complaint. the model was read from the assistant turns, so no messages meant no model, so the detail reported a bare `opencode`, which the composer cannot match — and it fell back to claude code and sonnet. it now also reads the model off the session record, which is populated before any assistant turn lands. the message shape moved with the endpoint: `type` where it was `info.role`, `content` where it was `parts`, and a user message's text sits on the message rather than in a part. both shapes are accepted rather than pinned to today's build — this reader is the only thing between a working transcript and a chat that looks erased, and a host on a different opencode should lose features, never its history. AND THE ORDER WAS BACKWARDS. the endpoint returns newest first and nothing downstream re-sorted, so the answer sat above the question that produced it. sorted here, because this is the only place that still has timestamps. NAMING: opencode is meant to title a session from its contents and does not, on this surface — sessions stay at `New session - <timestamp>` through a finished turn and past it, which is why the list was a column of "(untitled)". `isPlaceholderOpenCodeTitle` exists precisely because that name leaks into the UI. so the sidecar names a NEW session from the first line of its first prompt, which is what the claude side shows. a resumed session keeps what it has, and if opencode ever does title one it writes after us and wins. verified live on the session that prompted this: 4 messages, in order, model opencode/kimi-k3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
3b1d4cea84 |
permissions off wholesale, by every documented mechanism, plus a backstop
read the documentation instead of the binary, which is what should have happened
hours ago. it says three things this code was getting wrong.
- "permission": "allow" — a BARE STRING, not the object form. that is the
documented way to say "no prompting at all".
- most permissions already default to allow. the exceptions are
`external_directory` and `doom_loop`, plus `read` on `.env`. those are what
was actually stopping turns.
- an agent's own rules take precedence over the global config, and an agent
that declares none inherits the built-in defaults — so a global allow does
not reach it.
so: `permission: "allow"` in the serve config, AND the agent moved to the
markdown FILE form with `permission: allow` in its frontmatter. the file form is
what the docs recommend "for anything non-trivial", and it is the only place an
agent's permissions are honoured — the inline definition ignores both `permission`
and `prompt`, while accepting `description` and `mode`, which is the worst kind of
partial.
WHAT I COULD NOT MAKE TRUE. with all of that applied, the serve still reports
`external_directory: ask`, `doom_loop: ask` and `read *.env: ask` on the resolved
agent. i cannot get that list to come back clean, and i am not going to guess at
it again.
which is why the event-stream approver stays and is the thing actually doing the
work: whatever opencode asks, the platform answers `always`. that is the analogue
of the claude harness's `bypassPermissions` and it does not depend on predicting
the shape of the next gate. the config above is the belt; that is the braces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
3082949fac |
answer opencode's permission prompts, because nothing else can
the spinner had a cause and it was none of the four things fixed before it. the
serve was holding a permission request:
{"action":"skill","resources":["calendar-and-contacts"],"source":{"type":"tool"}}
opencode asks before loading a skill. in its own terminal a person answers. here
it goes to a UI that has never heard of it, and the turn does not fail — it SITS
THERE. no error, no log line, nothing in the transcript. three models were blamed
for it, and a paid provider, and the skill itself.
replying `always` by hand released it instantly: the skill loaded and the agent
went straight to the curl the skill describes. so the whole chain was correct and
one unanswered question was holding all of it.
CONFIG IS NOT ENOUGH, and this is the part worth keeping. the serve's config
carries `permission: {skill: {"*": "allow"}}`, the agent's own ruleset reports
`skill * allow`, and it asked anyway. answering is the only approach that does not
depend on guessing the shape of the next gate correctly — and there will be a next
gate, because `external_directory` was the last one.
not a widening: the claude harness runs `bypassPermissions` deliberately, because
CLAUDE.md is explicit that an unsandboxed agent on the owner's own machine is the
point. this is the same stance for the other harness. `always` rather than `once`
so a repeated tool does not re-ask every turn.
when opencode reaches members this becomes a real decision. it is owner-only today
for larger and unrelated reasons, and it goes on the list with them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
c226e8a46d |
copy the skills, do not link them — a link is what made it hang
the agent called `skill calendar-and-contacts` and sat there. not an error, not a refusal: a spinner, on three different models. opencode grants `external_directory` for the paths it discovered — literally `~/.config/opencode/skills/<name>/*` — and a symlink RESOLVES to the plugin directory, which is not in that list. so reading the skill body fell through to `external_directory: "*": "ask"`, and a server session has nobody to ask. the request waits forever. the permission list the serve reports says it plainly: every skill path is allowed at its LINK location and nothing allows the target. copying makes the real path the allowed path. the cost is drift — a skill edited in its plugin is not live until this sidecar restarts — which is why the copy is rewritten from scratch on every boot, and it is a good trade against a hang with no message. copies are marked so a skill somebody wrote there by hand is never clobbered and ours are replaced rather than merged, so a file deleted upstream cannot survive in the copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f0801808c4 |
read the documentation: four things wrong, none of them the model
an opencode agent could not look at a calendar all afternoon. i treated it as a
framing problem, then a regression, then a free-vs-paid model problem, and it was
none of those. the documentation says all four, plainly.
1. A SKILL'S `name` IS AN IDENTIFIER, not a label. it must match
^[a-z0-9]+(-[a-z0-9]+)*$ and equal its directory. every skill here carried a
human title there — "Calendar and contacts" — so every one was LISTED and none
could be LOADED. the agent found the skill, called
skill({name:"calendar-and-contacts"}), and got "Unable to load skill". `title`
is now a separate field and keeps the prose.
2. THERE IS NO `skills.paths` CONFIG KEY. it is in the binary's own config
example and not in the docs, and the difference is exactly this: skills
configured that way are offered to the agent and cannot be opened. skills load
from fixed discovery paths only, so each directory is now SYMLINKED into
~/.config/opencode/skills. links, not copies — one source of truth, and a skill
edited in its plugin is live immediately.
3. `prompt` IS A FILE REFERENCE — "{file:./path}". passing the text is parsed
without complaint and never applied. this is what made it look like a model
problem: the agent existed, the prompt was on its record, and the model had
never seen a word of it.
4. THE `skill` PERMISSION DEFAULTS TO ASKING, and a server session has nobody to
ask, so the call errors rather than waiting. {"skill":{"*":"allow"}}.
also: the platform cached opencode's model list for the lifetime of `officer`,
so signing into a provider and restarting the SIDECAR left the old catalogue in
the OTHER process — six free models, no paid ones, nothing saying why. the cache
is dropped when the sidecar reports a new port, which is the one event that
always accompanies a new serve.
verified: the prompt reaches the model — asked who it is, it answered "Officer,
running on your home server at officer-dev under your user account". 10 skills
discovered through the linked path with loadable names. NOT verified end to end:
a turn that opens the calendar skill and answers from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
a873690e27 |
opencode gets an identity, not just a coding agent's
three turns in a row could not look at a calendar while holding the calendar skill, and each failure had a different cause. FIRST: opencode's built-in `build` agent opens with "You are an AI coding agent. Help the user accomplish software engineering tasks." asked about a calendar it reasoned, in its own words, "this isn't a coding task, and I don't have access to any calendar" and never called the skill tool. the index was in its context the whole time. the plumbing was right and the identity was wrong, which is the harder of the two to see. so officer defines its own primary agent — mode primary, `default_agent`, and named explicitly on session create because a default is a preference and this is a requirement. only NEW sessions: a resumed one keeps the agent it was made with. SECOND: setting a custom `prompt` traded one problem for the other. with the built-in agent the index was there and the framing was wrong; with ours the framing was right and the index was gone, so the turn went looking for .ics files in the home directory. `prompt` replaces the assembled system text rather than adding to it. the index is now compiled INTO the prompt by `skillsBlock()` — the same function a claude session uses — so both halves arrive together. ours also carries the PATH to each SKILL.md, which opencode's own block does not: a path can be opened with the file tools every harness already has. WHAT IS NOT FIXED, AND IS NOT THIS FILE'S FAULT. a third attempt still failed, and a diagnostic turn reported system instructions beginning "Tool infrastructure You can call" — not ours — and named a skill (`frontend-design`) that exists nowhere in this install. the only provider this machine can reach is opencode's own hosted gateway on free models; there is no auth.json here. so either the gateway supplies its own system prompt, or a free model is confabulating about its instructions. those are different problems and neither can be told apart without a model whose credential we own. verified: the agent is registered and its prompt is 4,717 characters containing "Calendar and contacts" and the SKILL.md paths; the serve's credential works (a tool process fetched the owner's collections); 10 skills discovered. NOT verified: that any of it reaches the model on this box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
e867fe72af |
notify one named account — and unshadow /broadcast, which never worked
"can you send a notification to George" had no answer. an agent key reaches its
own holder and nothing else, and the only route that spoke about somebody else
sent to EVERYONE. so the choice was the whole household or nobody.
POST /api/notify/user, ownerOnly, same authority and same reasoning as
/broadcast: everything else under this permission acts on the account asking, and
reaching another person's phone is administration however few phones it is. it
returns the target's email so the caller can see who was reached rather than
trusting an id they typed, and 404s an unknown account rather than letting the
sidecar's silence read as delivery.
AND THE PART THAT MATTERS MORE. /broadcast has answered 404 since the day it
landed. `createSidecarProxy` mounts `router.all('/*')` when it is CREATED, and
hono matches in registration order — so every route added to the proxy's own
router afterwards was forwarded to the sidecar, which has never heard of it.
three days, on a route whose entire purpose was to stop people POSTing to the
sidecar's ephemeral port by hand. a shadowed route does not fail like a missing
one; it fails like a sidecar that has not implemented it, so the 404 reads as
somebody else's problem.
caldav's router learned this on 2026-08-21 and wrote it down. this file now does
the same thing in the same words: the platform's own routes first, the catch-all
proxy mounted LAST.
verified: /broadcast now answers 400 to an empty body where it answered 404
before, /user delivers and reports the address, /_officer/devices still forwards.
found while trying to reach around the notify sidecar's producer secret to do
this by hand — which is exactly what that secret was added to stop, and the
comment above it says so. it stopped me. the route is the way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e6da5d8c77 |
opencode gets the skills and a credential, which it had neither of
an opencode agent could not have used the calendar skill written this morning, and would not have failed usefully either — it had no idea the skill existed and no key to call the platform with. two gaps, one file each. SKILLS: OPENCODE ALREADY HAD THE MECHANISM. it has a `skill` tool, an index compiled into its system prompt, and `skills.paths` in config taking directories of <name>/SKILL.md — the same progressive disclosure claude does over the same layout the platform already stores. so this points it at the SAME FILES rather than compiling a second index in a second format. skills/load.ts says a skill is a path because every harness can read a file; this is the better version of that bet — the harness had the feature all along. verified: `/skill?directory=…` now returns all nine platform skills, "Calendar and contacts" among them. THE CREDENTIAL, AND THE THING TO KNOW ABOUT IT. claude spawns a subprocess per session, so its key is per session and a member's resolves to their own grants. opencode cannot: ONE serve runs every session, so the only environment there is is the serve's own, minted once at sidecar start, belonging to the owner. that is sound today for exactly one reason — opencode is owner-only, enforced at every door in chat.ts because one serve runs as one user. when that lifts, this key becomes a privilege escalation. it is the same blocker as the two already there, not a new one, but it lives in a sidecar where nobody looking at chat.ts would find it, so chat.ts now names it. all three move together. verified end to end: a tool process inside an opencode session ran curl with $OFFICER_AGENT_KEY and got the owner's calendars back. OPENCODE_CONFIG_CONTENT rather than editing ~/.config/opencode/opencode.jsonc — it is inline json, it MERGES into the same chain as the config files (checked in the binary), and it dies with the process. the alternative is the platform editing a dotfile it does not own and leaving state behind that outlives the install. affordances ride the `instructions` key as one composed file, written beside the serve's state. loaded — `/config` shows it — but NOT model-verified: this box has no provider credential in auth.json, so a turn 401s before reaching a model. whoever adds one should check that first. the credential module moved out of sidecar/claude/. it was never claude-shaped; it was just there first, and it had one importer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f00abe816e |
a sidecar proxy claims only the sub-path it forwards
registerProxiedPrefix tells bodyParser to leave a prefix's request bodies alone. that is correct for forwarded traffic and load-bearing for uploads: a parsed multipart body is re-serialised with a new boundary while the original content-type header is still forwarded, and every upload fails. but a plugin mounts its OWN routes under the same prefix, and those are not forwarded — they are served in-process and they need their bodies. claiming the whole prefix meant every field on them read as missing. caldav hit it the day it was extracted: POST /api/caldav/provision/ios answered "deviceLabel is required" for a request that carried one. its app passwords used to live at /api/dav, a second prefix that was not proxied, and one prefix per plugin ended that arrangement. so `proxiedSubPath` — caldav's sidecar answers `_officer/*` and nothing else, so it claims `/api/caldav/_officer` and the plugin's own routes are parsed normally. omitted means the whole prefix, which is right for every sidecar with no routes of its own, so nothing else changes. this affects any plugin mixing its own routes with a proxy, which is most of them. they have not hit it because their own routes are GETs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
68f3643df7 |
a pull leaves origin/master where it found it, so every checkout looked ahead
plugins:check reported "2 commit(s) NOT PUSHED" for all twenty-one installed plugins on a machine where nothing had been committed. the check was right about what it measured and the cause was here. `git fetch origin HEAD` writes FETCH_HEAD and nothing else. refs/remotes/origin/ <branch> keeps whatever it had, so after a successful pull the checkout sits at the newest commit while origin/master still points at an older one — and every `rev-list origin/master...HEAD` reports the checkout as ahead by however many commits it just pulled. so the remote-tracking ref is refreshed too, explicitly, after the fetch that cannot do it. best effort, because the local branch may not exist on the remote under that name — which is the same reason the fetch asks for HEAD rather than for a branch. fetch also asks for --tags now. tags are how releaseState decides whether there is anything to update to, so a fetch that skipped them left the Update button answering from refs older than the release it was offering. verified by staling a ref on purpose: one false positive before, none after a single pullPlugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
c14b686782 |
pm2 save when the process list changes, and a check that does not lie about symlinks
the owner asked whether anything runs `pm2 save` when a sidecar is added or removed. nothing did. PM2 resurrects from ~/.pm2/dump.pm2, written only by `pm2 save`, so the dump was whatever a human last happened to save. today it was FIVE DAYS old: 13 processes saved against 26 running. a reboot would have silently lost every sidecar installed since — bitcoin-wallet, desktop, gitea, invoices, iris, jellyfin, minesweeper, music, photos, soulseek, transcribe, transmission, vault. thirteen features gone, machine healthy, nothing saying why. saved after start AND after delete. removal matters as much: a dump that keeps resurrecting a deleted sidecar brings it back after a reboot as a crash loop for code no longer on the machine. a RESTART does not save — the same processes are running and the dump is already right. best effort and never fatal: a failed save leaves the estate correct and only the dump stale, which is where things already were. failing an install over bookkeeping would be the worse trade. separately, plugins:check filtered directories on the DIRENT's isDirectory(), which answers false for a symlink to a directory. platform/plugins is symlinked now, so the check would have reported "0 plugin repositories, all well-formed" — a green check over nothing, which is worse than a red one. it stats the path instead, the way discover.ts already does. dump saved once by hand as well, since five days of drift was already there: 26 saved, 26 running, no difference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
68d6dd942e |
update means something now: it depends on tags
"update" was `git pull` and re-install, unconditionally, for every plugin.
nothing could say whether there was anything to pull, so the button was always
there and usually did nothing — and "update all" was twenty-one no-ops with a
spinner on it. the button's own comment admitted it: gating on `outdated` would
hide it for ever, because nothing bumped a version.
versions are dated and tagged now, so the rule the owner asked for is possible:
· a checkout NOT at a tag is always updateable — it is between releases, no
claim is being made about it, pulling is always reasonable
· a checkout AT a tag is updateable only when a NEWER tag exists
the part worth defending is how "newer" is decided. the obvious version-string
comparison is wrong the first time a plugin is tagged `v2` or `1.0.0-rc1`, and
wrong SILENTLY — a plugin that never offers an update looks like one nobody has
released. so newness is asked of git: the newest tag reachable from the REMOTE
branch, compared against the tag at HEAD. that holds for any names anyone
chooses, and there is a test with a tag called `beta` that sorts before `v1` to
keep it honest.
it never fetches. every answer comes from refs already on disk, because this
runs once per plugin on a request that renders a list and twenty-one network
round trips would make the screen feel broken. tags arrive at clone and at the
fetch `pullPlugin` already does.
the VERB refuses too, not just the UI. a plugins list is fetched once and a tag
can be pushed a second later, so the copy someone clicks is routinely older than
the truth — and `updateAll` iterates a list rather than asking per plugin.
"up to date · <tag>" is said out loud rather than shown as an absent button. no
button and up-to-date look identical, and only one of them is information.
verified on the real installs: example pulled to its tag and flipped to
up-to-date, caldav on an older commit stayed updateable, and updatePlugin on
example answered "already at 2026.08.27.01, the newest release" without pulling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
d58f58813c |
the marketplace is browsed by tag, not scrolled
/marketplace was a list of every plugin on the left and a preview on the right. that is a fine control panel and a poor shop: twenty-one rows sorted by nothing anybody cares about, where "something for my calendar" means reading all of them. now: tags down the left with counts, cards in a grid on the right, and clicking a card takes over the panel with a back link. three things about the shape. BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a filter in component state is lost on Back, so filtering, opening a plugin and pressing Back would drop you in an unfiltered grid having lost your place. browser Back works for free for the same reason. selecting a tag also CLEARS the selection, because a detail for a plugin outside the new filter is a page the grid you are now looking at cannot return to. the detail REPLACES the grid rather than sitting beside it. a grid needs the width and a detail beside one gets half of it; neither reads well. the back link drops ?selected= and keeps ?tag=, which is the entire point of putting the filter there. the panel KEYS are unchanged though both components were replaced. a stored layout names them, and renaming would drop everyone back to the default arrangement for nothing — the key identifies a slot, not an implementation. two things found on the way. PluginItem and AvailablePlugin had no tags, so the api now carries them on both branches — from the manifest for a plugin that is here, from the catalogue for one that is not, same field either way. and the screen used useDashboardState, which persists the whole tree: the panels this ships would never have reached anyone who had opened it before. useAuthoredLayout takes the structure from source and persists only sizes, which is what an authored screen wants. 21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one rows, and the width is worth more to the grid. verified: bundle contains the new panels, api returns tags for all 21 with none missing the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
276b12607a |
every brief the platform writes, where both harnesses will find it
opencode discovers AGENTS.md and nothing else — its only target is
H.up({targets:["AGENTS.md"]}), verified in the binary. claude code discovers
CLAUDE.md. no overlap.
every brief the platform wrote was CLAUDE.md, so under opencode a commander got
none of its standing brief, a bot none of its arming instructions, and a plugin
chat none of its plugin's guidance. nothing failed and nothing was logged: the
agent simply knew nothing, which is indistinguishable from the feature not
existing, and is why it lasted this long. commanders can already run under
opencode, so this was live.
writeAgentBrief puts the content in AGENTS.md and a POINTER in CLAUDE.md. a
pointer rather than two copies because these are rewritten every turn, two
copies of that will eventually disagree, and the stale one is the one somebody
reads.
three writers, and the third needed a flag the others must not have:
bots rewritten every turn ON PURPOSE, so a template change reaches them
plugin chats same
commanders onlyIfAbsent — a commander EXTENDS this file over its life, and
rewriting it when the template changed would delete the job it
had agreed with the owner
two things that would have quietly defeated it. the file listing handed to a
commander said "CLAUDE.md — this file", which would have had it editing the
pointer. and backfill-commander-credentials appended to CLAUDE.md, which on a
new commander means burying a credential section inside six lines nobody reads —
it targets AGENTS.md where one exists, CLAUDE.md where it does not, so old
commanders still work.
plugins ship these too: email and proxy-manager's chat/CLAUDE.md are now
chat/AGENTS.md. the platform accepts BOTH source names, because a plugin is its
own repository on its own release cadence and dropping the old name would
un-brief any installed copy that had not updated — silently, again.
tested: both files written, pointer is not a second copy, idempotent, rewrites
on change, and onlyIfAbsent leaves an extended brief alone. verified on disk
after a restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
d381140d54 |
plugins say what they are for, and their checkouts stop lying about themselves
two things, and the second is why the first was painful. TAGS. the marketplace is going to be browsed rather than scrolled: tags on the left, the plugins carrying one on the right. a plugin declares its own in the manifest and the catalogue caches it on sync, exactly as it already does for label and summary — the alternative is editing a table every time a plugin ships, and a plugin that cannot say what it is. the vocabulary is CLOSED (PLUGIN_TAGS) because free text becomes homelab, home-lab, Home Lab and selfhosted inside a month, and a filter list of synonyms is worse than no filter. an unknown tag is refused at discovery BY NAME, so the author is told which tag rather than which array index. all 21 are tagged, each with a one-line note saying why, so a later disagreement is with a stated reason rather than a mystery. every tag has at least three plugins behind it — no filter opens onto an empty page. PLUGINS:CHECK. getting one commit into 21 repositories failed seven times and none of the reasons were about the commit: six remotes were HTTPS and cannot authenticate unattended, three checkouts were behind their remote, one URL had no .git suffix so the catalogue published a clone URL unlike every other, and offscale's fetch refspec named a branch that does not exist. that last one is why this is a script and not a habit. every command reported on a phantom — `git push` said "Everything up-to-date" while the branch looked diverged — and the repository was fine the whole time. twenty minutes spent in a repo with nothing wrong with it. none of it is visible until you try to push, which is the worst moment to learn it: mid-task, across 21 repositories, and every failure looks like it is about your change. so it checks the facts with one right answer — remote URL, fetch refspec, branch, upstream, dirty tree, ahead/behind — and exits non-zero. `--fix` is a separate flag so running the check can never be what changes your tree, and divergence is never auto-repaired because it needs a decision about content. verified by breaking each case on purpose and watching it catch them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
fdf50ce2a4 |
ship screenArtUrl, which the last commit left behind
|
||
|
|
52f209c69f |
caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own: |
||
|
|
e0df28ff5d |
a first-party plugin may claim a root path the platform reserved
caldav is not only /api/caldav. four of its surfaces are top-level and above the account gate on purpose, because DAVx5, iOS and Thunderbird send HTTP Basic on every request and have nowhere to put a JWT: /dav, the ios profile under it, and the two .well-known discovery paths a client probes before it has any credential. the mechanism for that already existed — unauthenticatedRootPaths, exclusive claims, first come wins. what stopped caldav using it was RESERVED_ROOT, and what that list was recording is worth being exact about: `dav` and `.well-known` are in it because the PLATFORM serves them today, not because they are dangerous in themselves. once caldav is a plugin, the thing the reservation protects IS the plugin, and keeping it would mean the platform permanently owning routes for a feature it no longer contains. so a reserved name is claimable by a first-party plugin and refused to everyone else. the owner drew that line today: fine for a plugin we wrote, and letting a stranger's plugin claim /dav is a question that needs thinking about properly rather than falling out of a flag. /api stays refused to us too — everything of officer's lives under it and protocolHeaders is the only sanctioned way in. .well-known needed two segments, which the single-segment rule refuses. allowed by NAME rather than by pattern, and the reason is in the same namespace: acme-challenge is how this machine gets its TLS certificates, so a plugin owning it could take the domain. caldav and carddav are on the list; nothing else is, for us as much as for anyone. the OPTIONS carve-out moved with it. hono's cors() answers every OPTIONS as a preflight and never calls the route beneath — which to a DAV client is not a preflight but the question "what can you do", answered by a header iOS refuses the account without. that was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route rather than naming it here. verified: server boots (totality passes), /.well-known/caldav still 301s and /dav/ still asks for basic auth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6b5c87da10 |
a plugin can be more than one screen
a plugin was one screen because the two things it declares — a mount prefix and a frontend — happened to be the same string. caldav is where that stops holding: one sidecar, one permission, one install, and two screens that have nothing to do with each other on screen. calendar and contacts are not sections of one page, and forcing them into one would be a worse product than the core screens they replace. nothing else moved. all eighteen plugins with a frontend use flat web/panels.ts, which is now "one screen, at the mount prefix, labelled by the manifest" — the same file, the same route, the same persisted layout key. the nested form is opt-in and caldav is its only user. the split of who declares what follows the rule this file already had. the DIRECTORY declares that a screen exists (web/<segment>/panels.ts); the MANIFEST says what to call it and which glyph to draw, because a directory listing cannot say either and those are a human's choice. the two are cross-checked and a mismatch is refused rather than reconciled: a segment with no directory renders nothing, a directory with no entry has no tile and no label, and both are the quiet kind of broken. the part that needed the most care is that a segment is a URL the plugin CHOSE — /calendar, not /caldav/calendar. mountPrefix is what makes it impossible for a plugin to shadow a core route, and a chosen segment steps straight out of it. so this is first-party only, refused at discovery, and tested: without that check this field is a way for any installed plugin to take over /files. screenId, not appName, is now the layout key and the panel allow-list's owner. two screens sharing one would persist over each other's sizes and each would accept the other's panels. existing plugins get screenId === appName, so no stored layout is orphaned — verified by regenerating. |
||
|
|
7e6cfc85af |
one resolver for where an email chat lives
the picker above an email composer listed conversations from the owner's home — a chat started somewhere else entirely, offered as if it belonged to the mailbox. the address had two resolvers. the chat socket knew `context: 'email'` and ran the turn in the account's directory; `GET /chat/sessions` did not, and fell through to `who.home`. so a message went into the mailbox and could not be found in the list directly above where it was typed. two answers to "where does this chat live" is one too many, and the one that was missing failed quietly. `resolveEmailAccountDir` in data-path.ts is now the only answer, and both call it. it verifies a client-named mailbox against disk rather than sanitising it into shape, which matters because the socket's caller ends in mkdirSync — an unrecognised value would not fail, it would CREATE a directory named after whatever arrived and run turns in it. `all`, the list's own sentinel for the unified view, is exactly the value most likely to arrive by accident. pure: no mkdir, no writes. the caller that needs the directory says so itself. tested, because a missing branch is what this was: the named mailbox wins, `all` never becomes a directory, a mailbox with no emails.db is not a mailbox, and no account at all resolves to the accounts root rather than to something invented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
be210c3ed4 |
a plugin declares its chat directory; email stops being hardcoded here
ChatPanelWrapper had one plugin's screen id compiled into a shared platform
package: `workspace.id === 'email'` → `{ context: 'email' }`. its own comment
said the general fix was a way for a plugin to DECLARE its chat context, and
that a second hardcoded id would be the signal it was needed. that fix exists
(`declared`), the email plugin now passes it, so the branch is gone.
it also could not fire any more, which is the part worth keeping. a plugin's
workspace key is `screens/plugin/<name>`, so email's id became `plugin/email`
the day it was extracted and the test silently stopped matching. a hardcoded id
does not fail loudly when the thing it names moves — it just stops being true,
and nothing said so for eight days.
two things on the backend half:
resolveEmailCwd now installs the email plugin's chat/CLAUDE.md. an email chat
runs in the account's directory, not the plugin's chats directory, so the
`plugin` branch never reaches it — the brief follows the chat rather than the
chat moving to the brief. a second plugin wanting this is the signal that
"which directory does this chat run in" belongs in the manifest.
and it verifies the mailbox exists before joining it. `accountEmail` comes from
the client (the mailbox picker's `?account=`) and this function ends in mkdirSync
— so an unrecognised value did not fail, it CREATED a directory named after
whatever arrived and ran the turn in it. the list's unified view uses the
sentinel `all`, which is the value most likely to show up by accident. the panel
drops it; this is the half that does not depend on the panel remembering to.
docs/claude-md-touchpoints.md updated: the copier now has two callers.
unrelated and NOT from this change: ModelSelector's "an unstarted composer gets
a default" fails on master too — verified by stashing these files and rerunning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
d58652bf14 |
a deleted bot stays deleted
the bot came back three times after being deleted, untitled each time, and the
mechanism is worse than an orphan process.
its watch had no record, so deleting it stopped nothing. the watch kept polling,
fired, and the hand-back resumed the session by transcript id — at which point
`claude --resume` RECREATED the transcript that had just been deleted. so the
bot reappeared in the list and did it again on the next fire. same shape
|
||
|
|
f6bd7564bb |
a bot names itself after it arms
the automatic title is the owner's first sentence truncated — "can you notifiy me when https://newweb.fofos…", typo included. that is a prompt, not a name, and it is what they scan a list of bots for later. so after arming: two to four words, the subject and what is watched about it. "newweb uptime", "NYT front page". explicitly NOT "Website Monitor Bot" — every bot is a monitor and the word earns nothing in a list of them. this also closes the hand-wave in the record step. it said "your session id is the file name you were told to use — if you do not know it, look at which transcript you are in", which is not an instruction anybody can follow. now it lists /chat/sessions?context=bots and matches on the title, because claude titles a session from its first message and on the arming turn that entry is you. which puts the three steps in a REQUIRED order, stated: find your id, write the record, then rename. renaming first destroys the only thing that identifies you in that list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
84d9c9d2d8 |
deleting a bot stops it, and a dead bot wakes up
a bot was deleted tonight and its watcher was still polling a website eleven minutes later. nothing in the product could find it: a bot's watch is a detached shell process, killing the harness ends the agent, and the loop it started is not its child. so a bot writes <sessionId>.bot.json beside its script — what it watches, the script, the state file, the pids. that record is the ONLY thing connecting a process to the bot that owns it, and the brief now says so in those words rather than as bookkeeping. the id is in the FILENAME because bots share a directory: deleting knows only an id, so this is a lookup rather than a scan, and a record whose contents are damaged still gets found and cleaned up. DELETE /chat/sessions/:id tears it down after forgetting the tables — children first then the recorded pid, same ordering as stopCommanderProcesses, so the loop cannot spawn a replacement between the two. every transcript in the returned chain, not just the id asked for. unconditional rather than gated on "was this a bot", because the record only exists in the bots directory and a branch on cwd is a second place to get it wrong. script and state go too, and only if they are inside the bots directory — an agent wrote that path. and the reviver sweeps bots on the same tick. a record with pids and none of them alive is a stopped watch; there is no armed flag and there should not be, since the record is written when it arms, so its existence IS the intent. the three-wake bound is shared, keyed bot-<sessionId>. the wake prompt forbids reconstructing a watch from memory when the record is gone: a watch subtly different from the one agreed is worse than none, because it looks like it is working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
8b3ad8418a |
the brief carries the mechanism, so a bot stops going to find it
arming one watch took eight discovery calls and a 307-line read of the commander skill before anything happened. the brief said "use the background job you already know" and it did not know it, so it went looking — and what it found is a document about spawning agents in a loop, almost none of which applies to a bot. so the mechanism is written down here instead: write the script in this directory, run it with the Bash tool and run_in_background, the script waits in the shell and exits when the condition is true, the exit is the signal, re-arm before ending the turn. plus the shape almost every bot is a variation of, with the twice-in-a-row rule already in it. and it says NOT to read the commander skill, by name. an instruction to look somewhere is an invitation to read everything on the way. one real fix rides along: the first bot wrote its script to /tmp, which does not survive a reboot. the template puts the script and its state file beside each other in the bots directory, and says to write the new state BEFORE exiting — otherwise the next arming fires on the same change again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f119bbdf93 |
a bot has defaults, so it stops asking
"notify me when x.com is down" is a complete instruction. the brief now says so, and lists every question it was likely to ask along with the answer it should use instead: one minute, twice-in-a-row before calling something down, follow redirects, ten second timeout, extract the smallest stable piece rather than the whole page. two of those are the difference between a bot somebody keeps and one they turn off. firing on a single failed request produces a watcher nobody trusts. and the FIRST run establishes the baseline without firing — otherwise arming a headline watch immediately announces the current headline, which nobody asked about. and it can be told who to tell in prose. "@Name [id]" carries an id, but "the todo guy" is how it will actually be said — so: look it up in the chat list, match on title, use it if exactly one matches and say which. several matches is worth the one question. no match is worth asking. the line between defaulting and asking is stated rather than left to taste: no address at all is a question, a missing interval is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
de538cfc1b |
/bots — one sentence arms a watch
a bot is a chat whose working directory is bots/, armed through the background job any chat can already arm. no scheduler was written and none was needed: handoff already means "poll cheaply, wake the agent only when it fires", which is the expensive half of a commander without any of its ceremony. ONE directory, not one per bot. a commander earns its own because it is a job with procedures and a ledger; a bot is a sentence and a watch, and a directory each would mean a creation step before anything can be said. sharing it makes the list free — every bot is a chat in that cwd, so the header's picker is the list of bots. it earns NO permission of its own. /bots rides `chat`, because a bot adds no surface: same routes, same socket, same confinement. the registry says so rather than leaving the next reader to wonder. the brief is the feature. it tells the agent to arm on the FIRST message and not interview — pick a sensible interval, arm, say what you armed in one line, and let the owner correct it, which is cheaper for them than three questions. it says the owner will never say "notify" or "nudge" and must not have to. it forbids the loop that wakes on a timer to look for itself, which is the expensive mistake available here. and it draws the line: a bot watches and tells, it does not fix — a request for work is a commander, say so and stop. bots joins commanders and plugins in confineUserTree, third time: a member's bot runs as the member and a 700 service-user directory kills the turn on its own cwd. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a2102c5b46 |
"generating" is not "has an outstanding background task"
the guard I added yesterday to stop the reviver interrupting a commander mid-work disabled the feature entirely, and it took a real case to see it. it asked collectRunningTasks, which counts BACKGROUND TASKS. a commander's watcher IS a background task, and it stays outstanding until it reports a terminal state — which a killed process never does. so every commander with an armed watcher had one outstanding by definition, the guard read "busy" forever, and the reviver could never wake the one thing it exists for. found on the deploy commander tonight: loop dead, pid file saying so, and the task "Platform Deployer watcher — polls origin for deploy/* tags" still marked running from its own revival six hours earlier. permanently true. this is the shape I criticised in the nudger yesterday — a guard that could only ever be right by accident — written by me a few hours later. the right question was always whether the AGENT is mid-turn, which the sidecar answers directly and which the nudger was already asking before I removed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
8a12d5094b |
a plugin briefs its chats with a file, not a prefix
a plugin said what its chats should know through promptPrefix, which rides every message. three costs, and the owner hit all of them: paid per turn forever, invisible as a design artefact because it lives in a typescript constant, and visible in the one place it should not be — he sent three words and watched a paragraph of preamble go out attached to them. so the platform now copies plugins/<name>/chat/CLAUDE.md into the chats directory whenever a chat starts there, and claude code reads it from its own cwd at session start. same words, no per-turn cost, and a file the person it is about can open and edit. COPIED, which looks like it contradicts docs/plugin-capabilities.md. it does not: that rule is about files the PLATFORM reads, where a copy goes stale and survives an uninstall. this one is read by claude code from a cwd, so it has to exist there — no indirection changes where that program looks. the staleness is answered by rewriting on every resolve rather than once at install, so the file is always this version of the plugin's and plugins:update needs no republish step. skipped when the content already matches, so mtimes do not churn. best effort: no brief is the ordinary case, and a chat that cannot be given one still runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
4adf96efa4 |
the picker stopped overruling the default
a new chat offered fable. not displayed it — SELECTED it, sent it, and the server honoured the choice it was given. the composer's fallback was providerModels[0], the first entry in the catalogue, which is not a default but an ordering accident. the file already carried a comment about the day fable was added above opus and every resumed session came back claiming fable; the same accident then quietly defeated the whole server-side default. so the fallback prefers the platform's default and uses catalogue order only when that model is not in the provider's list at all — which is every opencode model, where there is no officer-wide default to prefer. and a test, because a comment cannot hold a constant mirrored across two packages. it asserts the client's copy equals the server's, and that the value names a tier rather than the bare harness. both of the ways this default has been defeated are now failures a test catches: a drifted mirror, and a stored harness read as a choice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
367006fb5d |
a plugin can ask for its own chats back
a plugin's chats run in $DATA_PATH/<email>/plugins/<appName>/chats, built server-side and deliberately never sent to the browser. which left the plugin able to START conversations in its own directory and with no way to LIST them — the path is the one thing it cannot name. so /chat/sessions takes ?context=plugin&contextId=<appName> and resolves it the same way the turn does. the four call sites already shared cwdOf; it takes the identity now rather than just the home. the plain ?cwd= form stays for /chat, where the user picks a directory and knows what they picked. it is not a hole either way: listClaudeSessions reads that identity's own ~/.claude/projects, so naming another directory shows you your own conversations elsewhere, never anybody else's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
cb41a8fe16 |
a plugin's chats get a directory of their own
$DATA_PATH/<email>/plugins/<appName>/chats, resolved server-side from a context the client names. why a directory: claude code reads a CLAUDE.md from its cwd at session start, which is the entire mechanism behind docs/commander-directory.md. a plugin that wants its chats to know something specific writes it there once instead of paying for it in every preamble on the machine. it also keeps those chats off the account's home, where they were sitting on top of everything its owner has. the client sends context='plugin' and contextId=<appName> and NOT a path. commander-directory.md reversed exactly that call and the reasoning carries: a client that can name a directory can name any directory. getPluginChatsDir refuses a separator, same guard as getCommanderDir. and the part that would have broken quietly: confineUserTree chmods every sibling of home to 0700 owned by the service user, carving out only `commanders`. `plugins` now joins it and takes the same member ownership. without that this is the general_chat_sessions failure a third time — a member's turn dying on its own working directory before running anything, which reads as the agent being broken rather than as a permission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0ef1bbc3c4 |
a stored bare harness is not a model choice
CHAT_DEFAULT_MODEL appeared to do nothing, and the picker still read "claude-code" after a refresh. both because an account can have the BARE harness stored as its preference, and this one does: chat.defaultProjectModel and tasks.defaultModel are each 'claude-code'. the client selected it, sent it, and the server honoured a choice that means "whatever the CLI picks" — opus. the default only ever applied when nothing was stored, which was nobody. `claude-code` with no tier is a routing answer and a legitimate one — it is what decideResume assumes. as an answer to "which model", it is the absence of one. so it is now READ as unset, in all three places that ask: the chat's model resolution, the task resolver, and the client's initial selection. nobody's settings are rewritten. the row keeps what it says and "no tier chosen" now means what it always meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
355fcaf1e5 |
a chat's key follows its holder, minus administration
it was ['notify'], on the argument that "an agent may tell me things" is far smaller than "an agent may act as me", and that everything else an agent needs it already has because it runs as somebody, in their home, with their files. both true. the second is what undid the first. an owner's agent has a shell AS THE OWNER, unsandboxed. it can read .env, query postgres, decrypt a plugin's stored credential, docker exec into a container. so scoping its key to notify never made it safer — it made it unable to use the platform POLITELY while every impolite route stayed open. that became visible today: a chat asked to take a proxy host out of service correctly reported it could not, having read this file, while sitting one docker exec from the same container. a route through the platform is authorised, logged and bounded by a permission. one around it is none of those. withholding the first does not prevent the second, it only chooses which gets used. so: the holder's own authority, minus admin. a RULE against the live registry, not a list — a plugin installed next year works from a chat without this file changing. the alternative on the table was adding each plugin's key to an array here, which puts plugin names in core and hands every plugin to every chat, one line at a time, forever. execution needs no exclusion; authorize.ts strips it whatever a row says. admin is the one worth naming: an agent may use the server and may not administer it. accounts, grants and plugin installs stay with a person. and it is NARROWER for a member, which is where it counts — their agent runs as their own linux user with no owner environment, so this key is a real boundary resolving to exactly what their role was granted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
42d9068f7b |
sonnet is what runs when nobody chose
every surface with no model picker was quietly running opus. the fallback was the bare harness, `claude-code`, and bare means the CLI picks its own tier — agent-files.ts says so in as many words. so a plugin's chat panel, a nudge, a task with no configured default, any chat opened where there is nowhere to choose: all opus, for work that rarely needs it. the commander pinned to sonnet on 2026-08-25 after spending ~$0.26 of opus on one routing decision was the same problem noticed in one place. DEFAULT_MODEL could not simply be changed, because it is two questions wearing one constant. decideResume uses it as a HARNESS IDENTITY when neither side knows which sidecar a resuming session belongs to, and that comparison is between harnesses — a tier suffix there would be comparing something else, and the three failures its comment records are all delivery failures. so it stays bare and keeps its one job, now stated. CHAT_DEFAULT_MODEL is the other question, answered separately. a floor, not a ceiling: a client that names a model still wins, an account default still wins, and only "nobody chose" changes. tasks get the same treatment for the same reason and a stronger one — a task runs unattended, often repeatedly, with nobody watching the bill. the nudger held a copy of the string; it holds the constant now. interim on purpose. per-surface defaults, or something that reads the work rather than the caller, is the better answer and is not this. one constant, one place, easy to move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
ac25cd4860 |
do not wake a commander that is working, and never more than three times
two guards the first cut was missing, both about the same thing: a dead pid is not a dead watch. a watcher EXITS when it fires — that is the mechanism, since a background process reaches the model only by ending. so the healthy path is poll, find, exit, hand back, act, re-arm, and for the whole middle of that the pid file names a process that is gone. ticking into that window would have interrupted a commander mid-work to tell it its watch had died. so: if a turn is running, the exit is already being handled and the re-arm is the last thing that turn will do. and a bound, for the case the first guard cannot see. a commander that genuinely cannot re-arm would otherwise be woken every two minutes forever, each wake a real agent turn — the same unbounded retry prompts/deploy-commander.md records the first deploy watcher dying of. three wakes in six hours, then leave it alone; a live loop resets the count. an unreadable attempts file counts as tried, because failing towards fewer wakes is the cheap direction. the near miss that prompted this is recorded honestly in the file: a pid read as dead two seconds after being written and the process was in fact alive and mid-claim. the lesson is that a pid file is a poor instant answer to "is this watching", which is enough to justify both guards without claiming more than was seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
e1f5d01b90 |
a dead watch wakes its commander instead of staying dead
the machine rebooted 2026-08-24 at 12:40 and took the deploy commander's loop with it. a release was tagged the next day and sat there. the only evidence was a word missing from a card. the obvious fix is for the platform to respawn the loop, and docs/command-hq.md settled on 2026-08-18 that it must not: the agent is only notified about a process it started and is tracking, so a platform-spawned watch would run correctly, fire, and reach nobody. that trades a visible failure for an invisible one, which is worse — not watching is at least legible. so the platform notices and hands the turn back. the agent re-arms itself, the way it already does after every fire, and the process it starts is tracked. the owner's two conditions hold by construction: the commander is still the only thing that starts a watch, and the indicator still reads the pid file. DETECTION is the pid file, not watcher_armed. that column is a claim only one path sets and most commanders never take it — both commanders here read false while one was demonstrably watching. a pid file that exists with nothing alive behind it means a watch that was running and is not. which made stop ambiguous, so stopCommanderProcesses now removes the file. without that, a deliberate stop looks exactly like a crash and the reviver would ask the commander to re-arm what the owner just switched off. the commander now keeps WATCHERS.md — what each watch is, the exact command, the interval, what it does when it fires — because when the wake arrives the transcript is long past the turn that armed it and the platform deliberately does not remember the command. a commander predating the file may rebuild from JOB.md, a script in its directory, or the turn that armed it; inventing one it cannot find is forbidden, since a wrong watch looks like a working one. the owner is notified separately from the commander being woken. they are different audiences and only one of them can act when the wake does not work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
ffb4c3eedd |
a nudge always arrives
three refusals, all gone. the row stayed enabled, sentCount stayed 0, and whatever scheduled it reported success — so "hand this category to a chat you already have" could not work and said nothing. NOT LIVE was fixed elsewhere and the guard outlived it: fire() passes resumeSessionId and claude-manager resolves it into the SDK's `resume`, so a collected session resumes its own transcript instead of starting a second agent. that was the 2026-08-20 incident and it cannot recur through this path. ANYTHING ELSE LIVE refused on any agent anywhere on the machine, because the sidecar does not report working directories and the check could not tell another agent in this conversation from an unrelated chat. on a box running commanders that is always true. a ban on the feature wearing the clothes of a safety check. GENERATING was refusing to do something that already works. a session is one long-lived query() driven by a streaming input queue; a message to a busy one is queued and delivered when the current turn ends. waiting is fine, disappearing is not, and refusing meant disappearing. what it costs, and the owner's call: nudging conversation A while B is live in the same tree still puts two agents in one directory. a message that must arrive is worth more than a guard that could only ever have been right by accident, since it never knew the directories either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
538a623bb8 |
every agent session gets a key, scoped to notify
a commander has officer.env and can reach the platform. an ordinary chat agent had nothing — the only token in the tree lives in the MCP server's environment, which the agent's own Bash cannot see. so an agent asked to tell its owner something either could not, or asked to be handed a personal API key: the owner's full authority, pasted into a transcript. one refused tonight and was right to. minted per session, permissions ['notify'], expiring in a week, named session:<key> so re-arming replaces rather than accumulates and a survivor is findable rather than one of a hundred identical rows. "an agent may tell me things" is a far smaller thing to hand out than "an agent may act as me", and it is the whole of what was being asked for. everything else an agent needs it already has: it runs as somebody, in their home, with their files. widening this later is one entry in an array; starting wide and narrowing later never happens. in the environment rather than a file, because it dies with the process: nothing to leak, nothing to clean up after a crash, no file for a later session to find. the commander's credential is a file for the opposite reason — a detached loop reads it hours later. members get it too, through the allowlist rather than round it. those OFFICER_* names sit next to NEVER_ENV deliberately, and they are not that kind of thing: NEVER_ENV guards the OWNER'S credentials, which would make a member's shell more privileged than the member. this grants a member one capability they already have, over their own devices, and nobody else's. best effort throughout — a turn still starts if minting fails, and the agent is simply back where it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
fc474d3061 |
ask for the chat id, do not offer session names
it offered a choice between platform-13 and platform-94. those are the platform's own bookkeeping — the owner has never seen one, they change when a session restarts, and from outside two of them look identical. offering that choice asks somebody to guess. what they can actually get is the id in the address bar: open the chat you mean and it is in the URL after /chat/. ask for that, in those words, and take it as free text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
ea4b8a1e5f |
the ledger records which step finished, not just that one did
"done" stopped being enough the moment there was more than one step. an item transcribed but never filed and an item that went all the way through are different states, and a ledger that cannot tell them apart cannot resume, cannot report, and cannot answer "was this ever sent?". so: a line per STEP rather than per item. the same hash appears once for each stage it reaches — acted on, classified, dispatched, and where it went — and the latest line for a hash is its current state. append-only holds, because no line is ever edited. it is also the only way to find what got stuck. an item acted on but never classified is invisible otherwise: not in the backlog, because the trigger has seen it, and not finished either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
d1a41d6534 |
say what dispatch can be, and ask about retention last
a commander was told to ask what should happen to each category and never told what the answers could be. nobody starting a commander knows that either — it is the commander's job to know, not the owner's to guess. so the list is in the file: nothing, somewhere else on this machine, tell them, wake a conversation they already have, start an agent on it, run another task, send it to another machine. with the constraint that stops it becoming a wall: offer the two or three that plausibly fit what this commander produces. a menu of seven is the same as no menu. and retention moved to the end, after notifications. it was between dispatch and notifications, which asks what to delete before the owner has finished deciding what happens to the thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
de0590c25b |
write down the rule, not the result
"5 went to platform, 2 to personal" is a result and it is worthless a week later. CLASSIFICATION.md should hold what would let somebody else — or the commander next month — sort the same items the same way: what each category is for, what the giveaway is, and where it hesitated. the borderline calls especially. those are the rule's real edges, they are where it will be wrong first, and they are the only part the owner can usefully argue with. a scheme with no recorded doubt looks decided, and then nobody revisits it. same for DISPATCHING.md as each category's dispatch is agreed — what happens, to where, and why that category rather than in general. written as it is agreed, not afterwards from memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
1072e17193 |
dispatch is per category, and scp is not a workaround
two things, and the second unblocks the first. the dispatch question is per CATEGORY. sorting items exists because they deserve different treatment, so one rule for everything defeats the reason to sort. some categories get nothing, which is an answer. each one goes into DISPATCHING.md as it is agreed, in the owner's words as much as the commander's — that file is where somebody looks in three months to find out why a thing went where it went. and the rule that would otherwise have stopped it. "use the platform, never the thing behind it" is about not going ROUND a route that exists — writing to the database instead of calling the endpoint in front of it. it is not a rule against scp, ssh or rsync when the owner asks and the platform offers no equivalent. copying a file to another machine is exactly that: no route, so nothing bypassed. the owner's call, and the right one — a route for this would mean making the host list a real thing and deciding what a member may reach, to replace a command that already works for the one person who can use it. reachability, keys and logins are the owner's to arrange. the commander says when something is unreachable rather than trying to fix their SSH. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |