68f4fe61faca3ea9a46ec947da5ebabc30d335be
574
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
db96f53bc2 |
todos: a backburner column, and the parked items on it
backburner joins TODO_STATUSES and the board's columns; the three items in the backburner theme move onto the status, so parked is now a lane rather than a folder you have to remember to open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
03601db69c |
definitions instead of examples
every example is gone from the commander template. what replaces them is an exact definition of each of the four words, because an example is a thing to copy and a definition is a thing to reason from — and a commander copying an example produced a first turn that read like understanding and was recitation. trigger: the condition, on a schedule, answering one question and needing no judgement. action: the work done to ONE item, usually a task the platform already has. classification: which category a produced item belongs to — changes where it IS, never what it is. dispatch: doing something with a result beyond keeping it, off until armed, and the only one of the four that acts outside the commander's own directory. with the line that stops the confusion the first real conversation ran into: storing the result is NOT dispatch. every action keeps what it produced, here, by default. that is the action finishing, not a decision anybody has to make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
ddabf46bf3 |
the first turn asks one question, and gets its suggestion from the platform
three corrections, all the owner's, all from watching a real first turn. it asked too much at once. the section listed five things to settle, so the commander laid out the trigger, the action AND the destination in its second message. every question must now be answerable with "yes" or "no, do this instead" — if the reply needs a paragraph, the question was too big. turns are cheap; a wrong guess armed for a week is not. it must not raise the destination at all. output goes in the commander's own directory, by default and without discussion. i had written "beside the source is often exactly right", which was wrong and was the thing prompting the question. and the suggestion has to come from `GET /api/tasks` and the skills index — what this platform actually has — not from an example in this file. which it could not have, because the examples WERE the answer: the first-turn section spelled out nine .m4a files named memo-<date>-<time>, and the shape section named "fire on a new recording, transcribe it" as the simplest commander. it read as a commander that understood the job. it was a commander reading its own documentation back. those examples are gone or varied. the template was written from one commander last night and fitted it perfectly, which proves nothing. the test is the second one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
420cd07507 |
/todos: a kanban board over the todo/ tree, owner-only
The filesystem is the database: GET reads the tree through the same module the index generator now imports, PATCH splices frontmatter (status, priority, due, order) and never touches a body byte, and every write regenerates TODO.md. Columns open/doing/blocked/done, native drag between them, selection and theme filter in the URL. Registry entry 'todos' (kind admin, 22 entries, docs:check green), isSuperAdmin double-gate on the router. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a9dd264a72 |
a commander's children stay out of the chat list
/chat already hid commanders. it did not hid their spawned agents, and a commander that fires once an hour spawns one per run — each with its own transcript, titled after whatever its first message happened to be. one commander had thirteen sessions under its directory, twelve of them reading "Read /home/.../procedures/classify-t…". a loop's bookkeeping, filling the list you keep conversations in. listCommandHqOwnedSessionIds is the union: commanders plus their children. kept separate from listCommandHqSessionIds rather than replacing it, because a CHILD is not a commander — isCommander has to stay false for one or the composer would offer it the handoff watcher. /chat/live is deliberately untouched. it is the "what is running right now" panel, and a spawned agent that is still going belongs there — hiding it would mean an agent running with nowhere showing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
7e50134200 |
Stop could not stop a commander that had re-armed
liveLoopPid read the pid file as Number(text.trim()), which is NaN as soon as there are two lines. the voice-memos commander wrote its pid with `>>`, so after one re-arm the file had two — and NaN read as "no loop". the Stop button reported zero processes signalled and left both loops running, silently. exactly backwards: the more a commander was re-armed, the less stoppable it became. verified on alpha, where both pids are alive right now. so the reader is lenient — every live pid in the file, in order — and stopCommanderProcesses signals all of them rather than the newest. killing one of two leaves the folder still watched, which from outside is indistinguishable from Stop doing nothing. parseLivePids is split out and tested. the caller resolves a path under DATA_PATH, so a test of the whole thing would have been a test of this machine. three lessons from what the first commander actually wrote, into the template: - the DATABASE is a thing behind the platform, and it is the one that gets missed. that loop writes nudges with `psql $POSTGRES_URL` when PUT /api/chat/sessions/:id/nudge exists. worth knowing: a MEMBER's agent could not do this — spawn-as-member.ts builds an env allowlist and throws on anything unvetted. the owner's runs with the platform's environment. - refuse to arm without a credential or a target. that watcher already does it, and it is the difference between failing and running all night doing nothing while reporting armed. - overwrite the pid file, append the spawned list. which is the bug above, stated where the next commander will read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
dd6d0a8e12 |
the first turn: look, then ask
setup opened blank. the commander was told the name and the path and then asked whether it could guess the job — so the first message back was generic, and the owner had to explain the subject from nothing. it should read the reference path FIRST and open with what is there: nine .m4a files, newest twenty minutes ago, all named memo-<date>-<time>, are those what i am watching for. that is worth ten generic questions, and it shows immediately whether it is looking at the right thing. and the section now lists what has to be settled before anything is armed — what counts as a new item, what happens to one, where the result goes, whether there is classification at all, how often to look and that the number is a guess. "have a conversation" was too vague to act on. the name is a clue, not a specification. it says what the subject is and never what to do about it, which is how a commander ends up doing something nobody asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
5ee09a3fb6 |
act, classify and dispatch are optional; the ledger is not
a commander is a trigger, a ledger, and up to three steps between them. most need fewer than three, and the simplest useful one has no classification at all: fire on a new recording, transcribe it, write the text beside the original. at that point it is a schedule over a task that already exists, which is the right answer rather than a lesser one. so the layout is conditional now — classification/ only where there is classification, procedures/ only where an agent is spawned. a directory that exists and stays empty reads as something broken. and "never write into what you watch" was too wide. that was aimed at BOOKKEEPING — ledger, reports, logs, state — which must not pollute the subject. output goes wherever the job says, and beside the source with the same name is often exactly right. as written, a commander would have read it and refused to do the simplest thing anyone would ask of it. the ledger stays compulsory in every shape. the .md beside the .m4a is tempting as the record and is output-as-claim again: it breaks the moment either file is renamed or moved, which is the bug that started all of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0a242e15ba |
one directory layout for every commander
classification/ with a directory per category and an unclassified/ that always exists, plus procedures/, reports/ and state/. named for the SHAPE, not the payload. the voice-memos commander called it transcripts/, which bakes one job into the layout — the same structure has to hold for a commander over email or photos, and none of those produce transcripts. and the distinction that makes the two records make sense together: the ledger says whether an item was HANDLED, the directory says where it BELONGS. unclassified/ is finished work waiting on a person rather than a failure, so nothing re-runs the action on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
c6a92735d3 |
a commander is told its shape at creation
trigger, action, ledger — settled with the owner over several turns and true of every commander, so it belongs in the file each one reads at session start rather than being re-derived in conversation each time. the part that took the longest to see: an action that takes ONE item all the way through means a half-finished item never exists, so there is nothing to queue and no second watcher needed to notice it. this commander was built as two cycles — transcribe everything, then file everything — and the second cycle existed only to clean up after the first stopped early. and the ledger keys on a hash of the SOURCE. names change (a memo renamed from a phone), outputs move (a transcript filed into a category folder), and both of those had already caused the same file to be handled twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
cb2dcfa4cc |
the notify sidecar stops trusting whoever reaches it
to send one message to four accounts an hour ago i found the sidecar's ephemeral port and POSTed to it directly, once per user, with no authentication of any kind. that worked, which was the problem: the same door was open to anything else on the machine, including a commander with a shell. two changes. POST /api/notify/broadcast, owner only via `ownerOnly` on the permission — the supported way to say one thing to everyone. teams will narrow it later; today the choice is everyone or one person. and the sidecar now requires a secret on everything under /_officer/. it is generated per boot, held in memory, announced to the platform over the registration socket beside the port, and never written to disk. X-Officer-User could not carry this on its own, and my first attempt got that wrong: it says WHO is asking, and a caller on loopback can write it itself. only the secret says the PLATFORM is asking. so the check covers the whole /_officer/ surface rather than just the send route — /_officer/ devices takes the same header to decide whose phones are listed and deleted. what it bounds, honestly: a MEMBER's commander runs as its own Linux user and cannot read this process, so for every account but the owner's this is kernel-enforced. the owner's own commander runs as the owner, same as the platform, and is not bounded by this and cannot be — the same reason `execution` permissions are never grantable. the owner's decision, and this is a real improvement rather than a boundary. `bun run notify:test` posts to that port and will now be refused. it is a developer CLI on a box where the platform is running; left to fail loudly rather than given a way in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
40a03d987f |
tell an existing commander how to reach its owner
the voice-memos commander was asked for a notification and reached for its harness's own push tool — a different channel, which refuses to interrupt a live terminal. it did that because nothing had told it Officer has a route of its own. that rule is the harness's; the platform's route has none, and had already pushed to a phone twice with the owner sitting at the terminal. so the notify part is its own section now, appendable on its own, and the backfill lands it on a commander that already has a credential — which is every commander that predates this, i.e. the ones that need it. the backfill also stopped being all-or-nothing. it skipped everything after the credential check, so a commander with an officer.env could never receive anything added later. each section is now appended independently, keyed on a marker the section itself contains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
2d5e9835f5 |
the catalogue shapes a commander's words, not the commander
"it should always say Commander: <name>" was left to whether the commander
remembered to write it. that is not a structure, it is a hope — and the
producer's title reached the lock screen untouched, because both transports
built the alert from n.title rather than from the renderer.
so the code owns the shape and the producer only supplies values.
officer/core:commander is `Commander: {title}` / `{body}`; the commander
sends "Voice Memos", never the prefix. interpolation gains {title} and
{body} alongside {n} and {id}, for the same reason the others exist.
apns-payload and fcm now send the RENDERED text. that was the actual
delivery bug: the catalogue could say whatever it liked and neither
transport read it.
a placeholder that resolves to nothing takes its punctuation with it, or
`Commander: {title}` with no title ships `Commander: ` and every template
has to assume its values are always present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
478ddb13e9 |
a commander can say something in its own words
asked whether the voice-memos commander could send a notification, the
answer was no, three times over. the platform skill gives it the prefix
/notify and not the route. the route wants a catalogue code, not a title
and a message. and of the six codes that exist, none means "a commander has
something to tell you" — the nearest renders "Test notification".
so: officer/core:commander, one entry rather than one per commander, with
the commander travelling as {id}. arming a new one needs no catalogue
change and no app resync.
and `title`/`body` on the notification itself, which override the
catalogue. this is a deliberate exception to the rule the whole design
rests on — content does not travel — and it is the owner's decision, made
2026-08-24. a commander's output is one sentence only it could write; there
is no entry to render it from, and "1 item" is not worth waking a phone
for. sendoff.landed already carries the same relaxation for the same kind
of reason.
the control sits at the PRODUCER, not the transport. the commander's
CLAUDE.md now says what may go in those two fields and what may never — and
that a notification is an interruption, so send few. this file cannot judge
what is sensitive and does not try.
`title` is the opt-in, not `body`. the existing smuggle test passes a stray
`body` and asserts it does not reach the wire; a body that travelled on its
own would have silently defeated it. pinned on both transports.
FCM gets a `notification` block only for producer text — data-only is what
lets the app compose from its catalogue, and text with no entry to compose
from would arrive and show nothing.
worth recording: there is NO Notification Service Extension, in this repo
or the mobile one. mutable-content: 1 is set for something never built, so
aps.alert is what the phone actually shows and this works with no app
release. i said the opposite an hour ago, from the comments rather than
from looking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
00baa83a2c |
a commander's own files, beside its transcript
a commander writes into its own directory — procedures, reports, the loop it armed — and there was no way to look at any of it. /files cannot reach it and never could: the file browser serves two roots, `home` and `user-data`, and a commander lives at $DATA_PATH/<email>/commanders/<dir>, which is under the one /files does not use. so the Plan button becomes a files button on a commander. instead, not beside: plan mode is research-and-propose-until-a-human-approves, and a commander is the case where nobody is watching to approve. the control promised something that cannot happen there, while the thing genuinely missing was any view of what it had written. an EPHEMERAL panel, the same trade /files makes for the file viewer. /command-hq is a locked workspace with a three-item allow-list whose comments are explicit that its structure is code's rather than the user's; a persisted panel would mean unlocking it and widening that list. an ephemeral layout renders beside workspace.value and never enters it, so neither has to change. ?files=1 rather than a route, and a panel rather than a link: the transcript stays mounted and stays the page, and a link on this surface opens a second copy of the whole app in a new tab and loses the conversation you were reading. breadcrumbs stop at the commander for free — Breadcrumb renders relative to basePath and its home crumb IS the base. worth knowing that this is the UI's floor and not the server's: the root really is all of user-data, which is the owner's own data either way, so it bounds the navigation rather than the account. the button reads its state from props rather than useSearchParams. ModelSelector is rendered bare in its own tests and reaching for the router inside it failed all twelve — a control that has to be mounted in a route to render is not a control any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a3a8516e66 |
backfill: a credential for a commander that predates them
a commander made before credentials existed has no officer.env and no key row, so it cannot reach the platform at all — the state that made the first one curl whisper directly. deleting and recreating it would fix that and throw away the conversation, which is the one thing a commander IS: a long-running agreement about a job. so this is additive and re-runnable. a commander that already has one is left alone. CLAUDE.md is APPENDED to, never rewritten — it is a living document its commander has been extending, and surviving is the whole point of not recreating it — and only when it does not already mention officer.env, so a hand-edited one does not end up with the section twice. the credential section moved out of commanderClaudeMd for this: it is now written in two places, and the backfill needs a self-contained section rather than a slice of one file's layout. verified locally: minted for the one commander here, second run reported "already has a credential" and changed nothing, file is 0600. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
12a9605b01 |
a commander's bound is a rule, not a list
the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.
so a credential now narrows two ways, and both only subtract:
permissions an ALLOW list. exact, a snapshot. right for a credential
with one job.
excludedKinds kinds it may never reach, resolved against the LIVE
registry on every request. right for a credential whose
job is open-ended.
a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.
verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.
the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e29a4ee380 |
a credential can be narrower than its holder
a commander is an agent working unattended for months, and the only
credential in the tree lived in the MCP tool server's environment where
the agent's own shell cannot see it. so the first one built here curled
the whisper container directly on localhost: right answer, wrong door, and
a door the platform does not control.
api_keys.permissions. null is what every key a human minted still is —
their full authority, exactly what the password that made it could do. a
list narrows: the credential reaches only those permission keys, and still
only where the account itself could. intersection, never union.
checked BEFORE the owner bypass, which is the whole point. a Super Admin
consults no grants table, so a token carrying the owner's identity is
unbounded by construction — fine for a browser session somebody is sitting
in front of, not fine for a process that runs for months. this is the only
thing in the platform that can bound them. verified against the running
server: an owner key scoped to [account] gets 200 on /api/user/settings
and 403 on /api/users, /api/tasks and /api/plugins.
it propagates. a narrowed credential can write a task and run it, so the
token buildTaskApiEnv hands that script carries the same narrowing —
otherwise the script comes back through the API with full authority and
the limit bought nothing. the gate stashes it on the request for the one
kind of route that passes authority onward.
POST /tasks/:name/run: runs a script task, waits, returns code and output.
both existing runners need something watching them — the socket needs a
live connection, a job needs somebody to open Jobs. a commander has
neither. deliberately not a job: transcribing a memo takes seconds and
produces a file, and a queue plus a row plus a progress bar is a screen
nobody opened between the request and the result.
script-run.ts is the third thing this needed and the one worth having on
its own. the socket runner, the job runner and now this each carried the
same twenty lines — materialize, INPUT_ env, positional args, resolve cwd,
mint the api env — identical, with nothing saying they had to stay that
way. they differ in how they REPORT, which is real and stays with each of
them. what the script SEES is now decided once.
a commander gets its key at creation, in officer.env at 0600 inside its own
directory, and CLAUDE.md tells it where and says plainly that a sidecar
listening on localhost with no auth is still the wrong door. a file rather
than an env var because the detached loop a commander arms is spawned by
the agent and inherits nothing the platform chose. deleting the commander
revokes the row — the directory removal takes the secret, not the thing
that makes it work.
default scope is everything except `admin`: a commander that can reach
nothing gets widened in a hurry rather than thought about, and installing
plugins, managing users and changing server settings are never part of a
watcher's job. it is a default, stored per key, and for a member it
narrows itself against their role.
also fixed: POST /command-hq/commanders/new never awaited
createCommanderDirectory, so a turn could spawn before its CLAUDE.md
existed — the exact ordering that redesign was for — and the cwd it
returned was a Promise serialising as {}.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
121ddb9964 |
tasks: run straight from the menu when there is nothing to ask
a task that takes real options — which audio tracks to keep, what format to convert to — has to ask, and the runner modal is for that. a task that is one command over the clicked file has nothing to ask, and putting a form in front of it with one pre-filled field and a Run button is a click that decides nothing. derived, not declared. the rule is whether anything is left over once the file browser has filled in what it knows, which is two things and only two: file_path by name, and any input declaring autofill entry_name / entry_path. a default does NOT count — a default is a suggestion and the user has to see it to change it. agentic, pipeline, inline true and inline 'ask' always take the modal; the last because the choice is itself the question, and the one before it because the modal is where an ephemeral run's output goes. and the reason the transcribe task's one field came up EMPTY: inputs is a list in TASK.md and a map everywhere else. the app's own task-creation guide teaches the list form, so that is what gets written; every consumer reads the map. a list arrived at the form as Object.entries(array), key "0" — so the input rendered nameless, was never matched against what the browser auto-fills, and would have run with nothing in it. normalised in parseTaskMd, which fixes the form, the INPUT_<NAME> env vars and the positional args together. the map form still passes through. GET /tasks now carries inline and inputs so the menu can decide without a second request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6d673a1321 |
plugins: a plugin's tasks are read where they live
the last piece of docs/plugin-capabilities.md. skills, affordances and notification catalogues already loaded from each installed and enabled plugin's own directory; tasks did not, so the transcribe plugin shipped a task nothing could see — right-clicking an .m4a offered no Run Task at all. it turned out to be one function rather than several. every path that runs a task resolves it through getTaskByDirName and then reads implementation off the record, so the sources go in there and in listTasks and a plugin's task is runnable everywhere at once, identity unchanged: still a directory name, still what pipeline_jobs stores. the record carries `plugin` and that one field does the rest. updateTask and deleteTask throw on it, DELETE /api/tasks/:name answers 409 naming the plugin, and the capability screen shows whose it is instead of an edit and a delete that cannot work. without the delete guard it was quietly a no-op: rm -rf on a path in the owner's store that does not exist, then ok: true, and the task still in the menu. owner's store first, so a plugin cannot take over a name already in use. getTaskByDirName now refuses a name containing a separator. it always joined a caller-supplied string onto a path — from a websocket message and a job row — and searching several roots is the moment that stops being one directory nobody could escape. capabilityRootsFrom is gone; it was dead. capabilitySources replaces it and carries the provenance, with capabilityRoots the thin map over it that the loaders that only want paths still use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
c427cb5b61 |
plugins: a backend plugin can have settings
A plugin with no web/ has no route, no dock tile and no panel — so it had nowhere to put a setting. transcribe shipped exactly like that: installed, running, and configurable only by an HTTP call somebody had to already know about. The plugin DECLARES its fields and the platform renders them, on the plugin detail page — the one screen that exists for every plugin whether or not it has a frontend. A component would have survived settings too complicated to describe, at the cost of every backend plugin carrying a frontend to draw one text field, each looking slightly different. The platform stores nothing. It is a form over the plugin's own /config: GET to populate, PUT to save. So a plugin that validates before saving still does, and one that keeps a credential keeps it where it already kept it. A `secret` field is write-only across the boundary — never read back, rendered as a password, and left out of the PUT when untouched so an untyped box is not read as "clear it". The plugin reports whether it HAS one, never the value. Eight strings across all six locales, because the key type refuses anything else — which is the localisation work from earlier tonight doing its job. 1426 pass, tsgo clean, docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
e35ff5c7ad |
stt leaves the platform for the transcribe plugin
Speech-to-text is now plugins/transcribe — sidecar, its own config, its own skill and a task for the file browser's context menu. The transcription logic moved BYTE FOR BYTE: 416 lines whose every decision has a reason attached, from the two-pass detect-then-transcribe to the 25 language priming prompts to timeout:false. Verified identical rather than assumed. Removed here: api/stt/, server-settings/stt.ts and its mount, POST /chat/stt, POST /file-browser/transcribe, the FileViewer handler behind the button hidden last release, the Settings screen, the dead useAudioRecording hook and the orphaned locale keys across all six locales. blobToWav stayed. It lived in the same file as the dead hook and is still used by DictateDialog — unrelated to STT, and deleting by file rather than by use is how that would have gone. No permission entry to remove: STT never had one, living under server-admin, chat and files. The registry count is unchanged and docs:check is green. The plugin ships a task that will not appear in the menu yet — the platform loads a plugin's skills, affordances and notifications from its own directory but not its tasks. That loader is the remaining piece of docs/plugin-capabilities.md and is deliberately not built here. 1426 pass, tsgo clean, 18 plugins discovered with none broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
b6d6de7e53 |
commanders: a commander is a directory, not a session
Implements phase 1 of docs/commander-directory.md. The row owns its own identity
and its own directory; session_id becomes a nullable pointer to whichever
conversation is current.
The ordering was the bug. POST /command-hq/commanders took a sessionId the caller
already held, so a chat existed first and was filed as a commander second — and
the working directory is fixed when that session spawns, before any id exists. It
now takes {name, referencePath}, creates the row, its directory and its CLAUDE.md,
and returns the cwd to start the chat in. Nothing waits on the CLI.
commander-block.ts is deleted. Claude Code reads a CLAUDE.md from the working
directory, so what it injected per turn is written once at creation and the
commander extends it as the job is agreed. resolveChatCwd no longer trusts
msg.cwd at all: it looks the row up server-side. The containment reasoning that
made a client-chosen path safe still holds — it just no longer applies, because
the client has nothing left to choose.
Four things found in review, none of which the build had.
Ownership. confineUserTree chowns commanders/ once, at provisioning, so anything
created afterwards belongs to the service user — a MEMBER's commander directory
would be one their own claude cannot write into, failing as an agent that dies on
its first command in a directory it can see. Invisible because it works perfectly
for the owner, who has no osUser. Handed over on creation, best effort, and the
error is returned as well as logged.
getCommanderDir is guarded. Two callers rm -rf what it returns, and createCommander
inserts dir_name as '' before fixing it up from the serial id — an empty name
resolves to the commanders ROOT and would take every commander the account has.
The transaction closes that window; the guard means it does not have to.
The fallback in resolveChatCwd was silent. A commander whose row is not found runs
in the account's home with no CLAUDE.md, which reads as an agent that forgot its
job rather than a lookup that missed. It says so now.
And sv-SE is renamed alongside the other five locales. It did not exist when this
work was written.
Verified: SQL applied against a scratch database first — idempotent, several
commanders can hold a null pointer at once, one-per-transcript still enforced.
Then here: db:push emits no DDL and does not prompt, officer restarts clean, and
POST /commanders returns a real directory holding state/ and a CLAUDE.md.
1428 pass, tsgo clean.
The single existing commander was deleted rather than migrated, per the design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
2a0f688e10 |
plugins: installing one grants it to every role
The default was deny-everything: a newly installed plugin reached nobody, and every grant had to be added by hand, per role, on a different screen, by the owner. That made sense while the permission model was being built and there were no members. It stopped making sense once installing a plugin became a deliberate administrative act — somebody who installs a thing has already decided the server should have it, and a second decision elsewhere has one reliable outcome: it gets forgotten and the plugin looks broken. So install grants, the owner revokes. uninstallPlugin already removed those grants, so the pair is symmetric. ROWS, NOT A RULE CHANGE. authorize.ts still denies on absence. The other way to get this behaviour is to make absence mean allow, and that is the version that bites in a year: every capability with no row for any reason — a failed write, a half-finished install, a bug — becomes permitted and nothing looks wrong. The blast radius here is only ever a plugin's own keys; core, execution, admin and confined are unreachable from it. Super Admin is skipped because ck_role_permissions_not_owner refuses it outright and would fail the whole insert. The owner bypasses grants anyway. Also fixes something older that would have made this half-true. A role created through /api/roles began with NOTHING — DEFAULT_ROLE_PERMISSIONS was only ever applied in bootstrap, so a new role could not reach chat, files or terminal while Member could, and nothing said why. A new role now starts where Member does, plus every installed plugin. Leaving it would mean the policy held for roles that existed when a plugin was installed and not for any created after, which reads as a bug in whichever half you meet first. Verified against the live database with a throwaway role and a throwaway plugin: the new role seeded with the confined baseline plus every installed plugin, the grant reached all four roles and never Super Admin, and cleanup left the table exactly as it was. 1417 pass. NOT done: existing installs. browser, email and offscale are installed here and granted to nobody, because they predate this. A backfill is one query and is the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
54f9f24726 |
uploads land atomically: write aside, then rename
Bun.write(filePath, file) streams to the name the file will end up with, so for as long as an upload takes, that name exists holding a partial file and nothing watching the folder can tell. The failure is silent rather than loud — a truncated m4a decodes fine, it is just short — so a watcher transcribing voice memos as they arrive would produce a clean transcript of the first few seconds of a two-minute recording and report success. Writing to <name>.part and renaming makes the final name either absent or complete. Every consumer gets that for free: the phone's own uploadVerified, a watcher, an agent reading the folder. The alternative was each of them inventing "the size stopped changing", which cannot tell a finished upload from a stalled one. SendOff had the same shape and it matters more there, because that is a folder things arrive in unattended — neither a recipient watching it nor the push notification may name a file still being written. .part rather than a dotfile, deliberately: a leftover should be VISIBLE, named for what it was going to be, beside where it belongs. A failed write cleans up after itself; only an interrupted process leaves a fragment, which is the case worth being able to see. Nothing sweeps them — a job deleting files it did not create is a worse trade than a fragment somebody can spot. Server-side only. The phone uploads exactly as before and its verification read happens after the response, by which time the rename has landed. No new build. Verified: a watcher polling every millisecond through a 40 MB write never saw a partial file under the final name, and a real 8 MB upload through the live route left the file complete with no .part behind. 1422 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
8770f98dcd |
capabilities: read a plugin's where they live, stop copying them in
Four publishers copied a plugin's affordances, notifications and skills into capabilities/ at install. That directory is meant to be forked — core's work plus the owner's — so a fork accumulated files it did not author with nothing on disk saying which, and there was no good answer: commit them and carry another plugin's work, leave them and git status stays dirty. Copying was also wrong in two ways unrelated to git. A copy goes stale, because plugins:update pulls the plugin's directory and the copy keeps describing the old version. And a copy survived plugins:disable, so a disabled plugin's skill stayed in the agent's index — off, and still being announced. Now the loaders take a list of roots: the owner's store, plus each installed AND enabled plugin's own directory. capability-sources.ts resolves that list. No namespace is needed to keep them apart, because separate directories do it structurally rather than by convention. The owner's own win a skill name collision — their root is first and the merge keeps the first of each id — so a plugin cannot take over a name already in use. Affordances are deliberately NOT de-duplicated: several can be true at once. Notifications needed more than a root list, and it would have failed silently. Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml. Read in place the path is just notifications/catalogue.yaml and carries no identity, so every plugin code would have vanished with nothing raised. loadPluginEntries takes the identity from the manifest instead, which is better provenance anyway — publisher and appName are validated at discovery, where a path was only ever a directory somebody chose. A yaml key still cannot claim another namespace, and that is pinned by test. createSession is async now, only because resolving enabled plugins reads the database. Once per session; its single caller already awaited. Deletes all four publishers including api/tasks/publish.ts from earlier today, superseded before it shipped. app-store/assets stays — images served over HTTP, where the copy is what makes them reachable. NOT done: tasks. That store is read-write, so reads must span sources while writes stay in the owner's directory, and a plugin's task has to be read-only in the UI. Next. Two things the design predicted as orphans turned out not to be, and both were checked before deleting rather than after. capabilities/extensions/tool-loader is written by the platform's own bootstrap every boot, not published by a plugin. And skills/email-db has no source to fall back on — the email plugin ships no skills directory, so that file exists in exactly one place and was hand-written. Neither was touched. 1422 pass, tsgo clean, officer restarts and serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
99149443b7 |
plugins: a plugin can ship a task
Assets, affordances, notification catalogues and skills each had a publisher.
Tasks did not, which is the one that matters for reaching the FILE BROWSER'S
CONTEXT MENU: a task carrying `{ type: 'file', extensions: [...] }` already
appears on a right-click with no platform change, because matchesTrigger in
useTasks.ts does that work. What was missing was only delivery — the capability
had to be installed by hand beside the plugin and removed by hand after.
Flat rather than namespaced by publisher, matching skills. A task's directory
name is its identity everywhere: the taskDirName in a job row, the path a script
runner materialises, the label in a run history. Wrapping that in a publisher
would put one in all of those for a collision nobody has had, and a task claims
no authority — it is a menu entry behind a permission that already exists. That
trade is NOT available to affordances, which stay namespaced, because one that
could claim `officer/core:` would be claiming the platform's voice.
The test that earns its keep is the uninstall one. `tasks/` is the owner's own
directory and nothing on disk marks which entries came from a plugin, so the
scope of that delete is the only thing standing between an uninstall and
somebody's hand-written work. Proven rather than reasoned about.
Written for the Whisper extraction, where the transcribe entry has to arrive
with the plugin instead of being seeded separately.
1429 pass, tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
7370a422d1 |
stt: priming prompts for 23 more languages
The table had two entries, en and pt. Every other language got no prompt at all, which is why Swedish transcription had nothing steering it. The prompt is not an instruction — whisper.cpp conditions on it and IMITATES it. So it carries three things: punctuation style, which is the largest effect; the language's diacritics, which is the one that fails silently, because a Swedish prompt without å ä ö teaches it to omit them; and register, which here is one person talking into a phone rather than prose from a book. Each entry is an original scenario in that language's idiom rather than a translation of the English, because a literal rendering produces translationese and primes badly. Diacritic coverage was verified per language rather than eyeballed, and the CJK and Arabic entries use native punctuation — 。and 、for Japanese, full-width for Chinese, ، and ؟ for Arabic, ; as the Greek question mark. Three are lower confidence and worth a native read before they are trusted: ar, he and hi. Arabic is the structural one — it is written in Modern Standard, which is not how anyone dictates a voice memo, though it is close to how Arabic is written down and to what Whisper emits anyway. Kept on that reasoning; cut ar first if it reads wrong. Detection still chooses the language and the prompt still follows the detected one, not the UI locale. Keying it on the interface language would prime Portuguese audio with an English paragraph whenever someone runs the UI in English, and an initial prompt biases output rather than merely hinting. tsgo clean, prettier clean, 11 stt tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
c38f02afa3 |
roles phase 3: dev tools are a row, not a hardcoded name
rolePermitsDevTools() was `role === 'Developer'` — one line gating two real grants, a Postgres login role with CREATEDB and a rootless Docker daemon. A role the owner created at runtime could never have either, which defeats the point of roles being data. Two boolean columns on `roles` now carry it, and roleDevGrants() reads the row. A role that no longer exists reads as neither grant, matching "absence denies" everywhere else. NOT the `provisioningScript` D8 asked for, and that column is removed rather than left dead. A shell script stored on a role row and run at provisioning time is arbitrary root-level code in a database column, and D8's own warning says why that is worse than it looks: `usermod -aG docker` is root by a longer route. So the row CHOOSES from a vocabulary the platform already knows how to carry out — the same shape as `multiarch` on a plugin's osDependencies, for the same reason. The seed re-asserts both flags on every run for system roles, and only for them. Without that, existing rows take the column default of false the moment it lands and Developer silently loses its tools on the next deploy — every host already has these four rows, so an insert-only seed would have shipped that. `description` stays insert-only, because an edit made on one server must survive a deploy. `db:push` could not apply this and said nothing. Dropping a column while adding another makes drizzle-kit ask whether it is a rename; with no terminal it bails and the wrapper still exits 0, leaving the seed to fail against an unchanged schema. Recorded in src/databases/CLAUDE.md with the SQL, because it will hit every host on the next deploy and no agent driving a rollout has a terminal. Verified here: SQL applied guarded, Developer keeps both grants and no other role gained any, second push emits zero DDL and no prompt, 1423 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6e092c32ea |
marketplace: minesweeper in the catalogue
Without an entry the plugin is invisible to every server but this one — the catalogue is what a remote host reads, and the bundled constant is only the fallback when marketplace.officer.dev cannot be reached. Its git remote was the tailnet address, which marketplace-sync would have published verbatim: a clone URL that works on this network and nowhere else, failing at authentication in a way that reads as a broken plugin rather than a wrong catalogue. Repointed at gitea.officer.dev, which is the rule the sync script's own comment already spells out. Synced. 18/18, and the entry is live with the right source, permissions and six osDependencies; the repo answers an anonymous https clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
fbdfd3fb89 |
minesweeper leaves the platform; plugin sockets relay ws-to-ws
Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper). Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the permission registry entry, the App.tsx route and the hand-written websocket provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3. The relay changed transport, and that is the part worth reading. A sidecar announces exactly one port. That single announcement is the whole provenance argument in target.ts — the platform watched the port arrive at registration, which is the only thing separating it from a number a manifest asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed command vocabulary over the registration socket. That is the mechanism the fleet has spent months retiring, and Minesweeper would have been the first thing to go back to it. Relaying websocket-to-websocket dissolves the conflict. The sidecar serves HTTP and /ws on the one port it announced, exactly as the pty sidecar already does; createSidecarProxy handles the HTTP half; and the conversion from frames to whatever the plugin actually speaks happens inside the plugin, which is the only process that knows. The relay is still ignorant of the payload — the ignorance sits one layer up. Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use for it and forwarding one puts a bearer token into whatever that process logs. X-Officer-User carries the only part it needs. (The terminal relay still forwards its token to the pty sidecar; not touched here.) Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f2f3ca12ba |
a plugin websocket targets a sidecar, never a port a manifest picked
The fixed-port form shipped this morning and lasted about an hour. The owner asked
whether declaring a port was safe, and it is not: nothing stopped a manifest naming
a port it does not own, so `websocket: { port: 5432 }` plus a grant would have been
a raw TCP relay onto Postgres — authenticated by the platform, available to anyone
that plugin was granted to. Port 22 and officer's own port are the same sentence.
The relay checked that the HOST was loopback and I mistook that for having checked
anything about the port. They are different questions and only the second one
matters: loopback is exactly where everything worth reaching already is.
So the only form is `{ sidecar }`. A sidecar's port is trustworthy for a reason a
declaration can never be — the platform watched it arrive at registration, so the
association between a port and a plugin is observed rather than asserted. The same
distinction that decides the delegation problem in todo/agent-coordination: a claim
is not evidence, whoever is making it.
The cost is that a plugin wanting a socket needs a sidecar. That is not ceremony,
it is the thing that makes the port mean anything. It was added as a shortcut for
Minesweeper, whose x11vnc listens on a known port — which is precisely the case
that looked harmless and generalised into a hole.
Nothing had used it. Minesweeper is still whole in the platform and its extraction
had not started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
eba0e86758 |
a plugin can serve a websocket
Bun's route table is fixed at serve() and WS_ROUTES is read once before it, so a plugin installed afterwards could never add a socket route. That was a hard ceiling, not a preference: /minesweeper had to live in the platform and plugins/desktop could not stream a display at all. The way out was already in the tree and had one consumer. dev-proxy serves a socket with no WS_ROUTES entry, through a WILDCARD route whose handler calls server.upgrade itself. Nothing has to become dynamic, because a plugin name is a path segment rather than a route — one wildcard declared at boot serves every plugin that will ever be installed. Authorisation is the PLUGIN's own capability, asked as if the socket were a GET against its HTTP prefix. That is the safety argument: there is no second permission to grant, forget, or drift, so granting a plugin grants its socket and revoking it closes it. It is also why plugin-ws is exempt from the boot totality check rather than carrying a registry entry — one entry covering every plugin's socket would mean granting one plugin granted them all. A target is a TCP address and never a URL, which keeps the relay ignorant of what it carries. That ignorance is the only reason one relay can serve an RFB display and a line-oriented sidecar. Loopback only and not configurable: a plugin naming an arbitrary host would be a request-forgery primitive handed to every plugin author. sidecarPort() is new because createSidecarProxy learned announced ports per-proxy and that state was unreachable from anywhere else — a second subscription would have been a second thing to keep in step. Verified: boots past the totality gate, and both an existing and a non-existent plugin refuse 401 before revealing which. NOT yet verified: that it relays a byte. Minesweeper is about to be the first caller, which is what proves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
04c1a6bfc9 |
role CRUD, with a guard the spec did not ask for
Phase 2 part 3 of docs/roles-and-teams.md. Create, rename and delete, each returning a discriminated result rather than throwing, so a router can say which rule refused and why instead of parsing an error string. Written by a Sonnet subagent against the plan; reviewed, exercised against the live database, and amended here. The amendment is the reason a review was worth doing. renameRole changes roles.name, and there is no foreign key yet — part 2 ships ON UPDATE CASCADE as its own release — so a rename would leave every holder pointing at a name that no longer exists. It is unreachable today only by accident: manage-users.ts validates against the USER_ROLES union, so a runtime-created role cannot be assigned and a non-system role has no holders. That evaporates the moment part 4 makes UserRole a plain string, and parts 2 and 4 could land in either order. So rename now refuses while anybody holds it, sharing one holder-count definition with delete so the two cannot disagree about what "in use" means. It comes out when the cascade lands, and then the cascade does the job properly by moving holders rather than refusing. Adding that reason made the router's switch non-exhaustive, which the typechecker caught — the discriminated-result shape earning its keep on its first change. System roles refuse both rename and delete: the platform's own code names them by string, so renaming one is a code change that has not happened. Delete never reassigns holders to a fallback, because that is a permission change nobody asked for. Exercised end to end: duplicate, empty name, system rename, system delete, a real rename, a rename onto an existing name, delete, and delete of something absent. The four seeded roles survive with the owner counted against Super Admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
44ee444cac |
the owner is an identity, not a role
Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which was right while roles were a hardcoded union and nothing could write a new one. It stopped being right the moment roles became a table: from then on "add a role" and "become the owner" were the same feature, reachable from whatever UI manages roles. So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and `isSuperAdmin` delegates to it, so no call site changes who it admits — widening any "owner-only" route to "owner or Super Admin" is a separate decision per route, not a side effect of this. Still a database read rather than a bare comparison: a deleted or blocked account must not remain the owner because its id happens to match. `getOwnerUser` becomes a primary-key lookup. It used to select on the role with `order by id` as a tiebreak, because two holders would otherwise make "who owns this server" whatever Postgres returned first — an answer that decides the agent sidecar's identity, vault access and origin scoping. That tiebreak, the write-path refusals and the CHECK all protected an answer that is now a primary key. Several Super Admins are allowed. What the role carries is real and belongs in the UI rather than a refusal here: it provisions an OS account with sudo, which puts the holder outside Officer's permission system and cannot be undone by revoking the role afterwards. That is the operator's decision, not this endpoint's. Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning user 1's role is KEPT — redundant for ownership, but it still forbids the owner being set to Member, which is a nonsense state that costs nothing to prevent. And promotion to Super Admin is allowed while creating an account directly as one is not: promotion happens to an account somebody has looked at, creation is a form filled once, and the role that grants sudo should not be a dropdown default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f6b9ddc877 |
a spawned session says who started it
The other half of making a spawned agent visible. The commander's pane now lists its children; opening one gave no way back — no link to the conversation that owns it, and nothing to tell it apart from a chat somebody had started themselves and forgotten. `parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from the other end. So this needed no table and no write path: a commander's children were recorded because deleting one has to take them with it, and that record answers "who is my parent" for free. Nothing had asked. `GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line above the transcript linking back to the commander. Null for almost every session, which is the normal case rather than a miss — a chat somebody opened has no parent — so the bar renders nothing at all rather than saying so. Never fatal: a session must open when this lookup will not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
66234cd3e0 |
an endpoint for spawning an agent, so a caller that is not a chat can
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.
So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.
The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.
`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.
Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.
Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2137d06498 |
the folder name is served, not derived three times
Mobile asked for `/targets` to carry each person's display name as the folder will be named, so a picker can honestly say where files will appear. Right request: the writer already computes that exact string to create a directory, and any client computing its own would eventually disagree with it — which means telling somebody the wrong place to look. So `displayNameFor` is the one definition and the writer calls it too. `/targets` gains `displayName` per person, and `myFolder` for the whole response: files land in the RECIPIENT's tree under the SENDER's name, so that is one value rather than a field on each row. The two are easy to confuse and the distinction matters — a picker showing `displayName` as the destination folder would name the wrong one. The web panel now uses both instead of its own copy of the fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b94a5e90b8 |
SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push. No staging area, no transfer table, no ack, nothing to expire. Every account already has a home, a file browser over it and a Files app on the phone, so a delivered file is an ordinary file from the moment it arrives. `Documents/SendOff/<machine>/` for something you sent yourself, and `Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually browses — the owner's real login home, a member's managed home — because the two are different directories and writing to the wrong one makes a delivery invisible rather than wrong. CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant answers "may this role send files", and the person who should decide whether somebody may write into your home is you. Absence denies, and a sender who was not allowed does not see the recipient in their picker at all — the endpoint answers 404 for "no such account" and for "did not allow you" alike, so it cannot be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving still needs their say-so. The sender's folder name comes from the token and never from the payload, so it cannot lie about who put a file there. Names are sanitised to one path segment and filenames to their basename — verified: `../../../../etc` becomes a literal folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the sender cannot see the directory and would have no way to know. The push carries the folder name and a file count, and the catalogue renders "3 file(s) from andre" on the device. That relaxes "content does not travel" by a folder name and an integer — the owner's call, on the grounds that the file never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would be content; whose folder it is, is addressing. `kind: 'app'` rather than `confined` even though it writes into a home: the path is one fixed shape chosen by the server from the caller's token, so there is nothing to traverse and nothing to read back. Requiring a Linux user would mean an account without one could not be sent a file, which is most of the point. Backend and verified end to end. The web send form is next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aa35428dde |
usage limits are read live, and a cached reading belongs to one account
`limitsCache` was keyed on nothing. Switching account changes which credential `readOauthLogin` returns, and the cached entry was served anyway — so the panel showed the previous account's percentages under the new account's name for a minute, and on a failed probe for as long as the probe kept failing. Reported maxed out on one account, switched, and the numbers did not move. So the limits have no TTL now: every request probes. They are per-account and they are the number somebody opens the panel to read, so a stale one is worse than a slow one. The store stays, for the failure path only, and keyed on the account it was measured for. A flaky request degrades to that account's own last reading rather than to a blank panel or — much worse — to somebody else's numbers. A reading that cannot be attributed gets no fallback at all rather than a shared one. The client's five-minute stale window went with it: that query is only the instant first paint, and holding it meant painting the old account's stored row until the refresh landed. The transcript scan keeps its cache. It reads ~90MB and its answer is the same whichever account is signed in — it counts what this machine wrote, not what an account is allowed — so a switch does not invalidate it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
854f957058 |
a recorded pid means armed, whatever route started the loop
`watcher_armed` is set on one path only — the background-job dialog's hand-back-and-re-arm. A commander that starts its loop as an ordinary tracked background job records its pid correctly and still reports unarmed, so Command HQ shows nothing to stop. That is the one thing the column exists to make possible. The pid file is the fact and the flag is a claim, so the fact wins: armed if the row says so OR a recorded pid is alive. Verified against a live commander that had armed itself and read as unarmed. Deleting it already worked — that path reads the record rather than the flag — so this closes the gap between what the platform could do and what it admitted to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
91bc4ac74f |
the platform keeps the commander's name
The name only ever lived in the transcript's summary record, which the harness cannot write until the first turn ends. So the list showed the opening brief until then, while the header beside it showed the name from local state, and every fix for that was a cache-invalidation trick timed against somebody else's write. Three attempts, all of them fixing the symptom. `command_hq_sessions.name` is written when the commander is filed, before the first turn produces anything, and the list and detail routes prefer it. Nullable, so rows filed before the column fall back to the transcript. The rename still happens — it is what titles the conversation everywhere OUTSIDE Command HQ — it just is not the source of truth any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a51a969cd0 |
a file with no turns in it is not a conversation
Four "(untitled) · 0 msgs" rows accumulated in the sessions list from one evening of commander testing. They are transcripts containing only harness metadata — last-prompt, ai-title, atis-latch — written after a session was deleted or before it ever sent anything. Filtered at the list, not deleted on disk: the files belong to the harness, and a cleanup racing it would be the same class of bug that produced them. Which is also the cause of two of the four. The delete path resolves officer's session key from two in-memory maps, and both answer only for a session younger than the process. After a restart neither does, so no kill is sent, nothing is awaited, and the transcript is removed while the harness is still writing — which writes it back. Same failure as the events cleanup, same durable answer: the event rows carry both ids, so ask them when the maps cannot say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5c802b07b5 |
a command dropped for want of a sidecar has to say so
`sendFire` looked up a sidecar, and if none was registered it did nothing. No log, no error, no return value — the command simply stopped existing. That window is real and entirely predictable: every `pm2 restart officer` has one while the sidecars reconnect, and somebody clicking a button has no idea they are in it. The cost landed hardest on answering a question. `answerClaudeQuestion` in the agent documents this exact failure in a careful comment and cannot report it, because the command never arrives to be reported. So the question card kept spinning, which from the person's side is indistinguishable from a slow agent, and the only move left is to send another message — the worst possible thing to do to a waiting tool call. Observed tonight, on a restart I ran myself while a commander was mid-question. So `sendFire` returns whether anyone took the command and warns when nobody did, and the two paths where a person is actively waiting — a question answer and a plan decision — tell them instead of leaving the card to spin. The other five callers are unchanged and now leave a log line, which is the part that was missing when this was diagnosed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2aef23c8ba |
a commander starts its own conversation, instead of being handed one
Starting a commander meant writing a good opening prompt, which is the hard part of the whole thing — so in practice somebody else wrote it and pasted it in. That is not a flow, it is a person acting as the intake form. Now the two decisions that cannot be guessed stay with the owner (a name and a working directory) and the rest is a button. It sends a brief the MODEL reads — "I have not told you what I want yet, orient yourself and ask me one question" — while the transcript shows "Set me up.", which is what a person would have typed. That split is `effectivePrompt` vs `displayText` and the chat path already had it. What to ask, in what order, and to read the directory BEFORE asking anything, all live in the Commanders skill under "When you started yourself". Deliberately not here: capabilities are re-read from disk every run, so rewording a question costs nothing, while the same words in commander-block.ts cost a restart each time. The block carries a pointer and no doctrine. Orienting first is what makes this work on a real request. Told "automate the platform deploys", a commander that has read the directory finds `deploy/`, the previous releases and the deploy skill, and asks whether that is still how you want it — instead of inventing a scheme next to the one already there. `sendPrompt` gained an options object rather than a tenth positional parameter. Nine is already past where a call site reads as a row of undefineds. The composer stays enabled beside the button: typing your own opener still works, it just is not the only way in any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eefcb06e35 |
stop the harness before deleting its transcript, and wait for it to actually stop
Deleting a commander removed its transcript and the `claude` CLI wrote it straight back. Verified rather than inferred: the file returned with 22 lines, every one timestamped after the delete, and the deleted commander was sitting in /chat as "(untitled)" — no longer in `command_hq_sessions`, so nothing filtered it out. Delete a commander, get an untitled chat back. Two causes, and the first one hid the second. The kill never happened. `killClaudeSession` was imported straight from `claude-manager` into a route that runs in `officer`, while the session map lives in `officer-claude-code`. It read an empty map in the wrong process and returned false every time. `claude:kill` and `killClaude` already existed for this; the route just wasn't using them. And killing is not being dead. `abort()` returns while the CLI is still unwinding, and officer does not own that file — the CLI does. So a session now carries an `exited` promise resolved by the same exit hook that already reaps it, `claude:kill` replies only once that resolves, and both delete routes await the reply before touching the filesystem. Bounded at 5s and never fatal. A child that will not die delays a delete by seconds instead of hanging it, and a sidecar that is down leaves the caller with exactly the race it already had. `markExited` fires before `dropDeadSession`'s identity guard, deliberately: a waiter has to be released even when a newer session has taken its key, or it blocks for the full timeout on a process that died on schedule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ac3ca7fe5 |
forgetting a conversation needs both of its ids, not one twice
`chat_session_events` is keyed on officer's SESSION KEY. The other three tables are keyed on the harness's TRANSCRIPT ID. Both are uuids for the same conversation, and `forgetChatSessions` took one list and passed it to all four — so the events delete matched nothing. Not sometimes: by construction, every time. A deleted conversation left its entire durable output behind. It survived two verifications because counting events BY TRANSCRIPT ID also returns zero by construction, and zero read as "cleaned". What settled it was reading an orphaned event instead of counting it — the payload carries both ids side by side and says outright which key the row is filed under. So the parameter is now named for its id space. That is the whole defence and it is the only one available: nothing else distinguishes the two, since both are uuids of the same shape, and a positional `string[]` cannot be got wrong loudly. Empty `sessionKeys` is legitimate and common — a session spawned by `claude -p` outside officer's websocket never had a key and never wrote an event, which is exactly a commander's children. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3837af657d |
live sessions default to agents only, and you ask by name for a commander
`?kind=` was opt-in, so a client that said nothing got everything — which is the wrong default for the one caller that cannot cope. The phone swipes between live chats one at a time, has no room for tabs and no way to skip a row, so a commander in that list is a commander you are stuck on. Defaulting to `agent` means forgetting the parameter can only ever under-report. The two callers that want both now say `kind=all` explicitly, and that is a fair price: they are few, they are on screens built to show both kinds, and they are the ones positioned to notice when the answer is wrong. `counts` still describes every live session whatever the filter, so a filtered caller renders both tab numbers from one request rather than two. Also corrects useLiveSessions' comment, which said `isCommander` was derived from the cwd. It stopped being true when a commander gained the right to choose its own working directory — it comes from the `command_hq_sessions` row, and the directory answers nothing. Unrelated but caught by docs:check on the way past: the registry gained a nineteenth entry (minesweeper, execution) and CLAUDE.md still said eighteen, which also left the generated platform skill two lines stale. Both corrected; the check is green again. The minesweeper feature itself is half-landed and not mine — `rfb.ts` fails typecheck and its registry test fails on a websocket that is not served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
749c80c877 |
the server says which live sessions are commanders, and can return just one kind
/chat/live already classified every row from `command_hq_sessions`; what it did not do was filter, so each client repeated that itself. Fine for the web list, which shows both tabs at once. Wrong for the iPhone app, which swipes between live chats, has no room for tabs, and must simply never be handed a commander. `?kind=agent|commander` does it server-side. Counts always describe the whole set regardless of the filter — the web tabs show both numbers side by side and both must be right whichever tab is open, so a filter that also hid the other count would force a second request to render one screen. An unrecognised kind is a 400 rather than "everything": a mistyped `?kind=agents` silently returning commanders is the precise swipe-order bug this exists to prevent, so the plural forms are accepted too, since the client's own type is already plural. And /chat/sessions no longer tags rows it is about to delete. It called listCommandHqSessionIds twice — once to mark commanders, once to remove exactly those rows four lines later — so isCommander was false on every row that survived. A field that could only ever lie, plus a second identical query per request to produce it. A list that excludes commanders has nothing to say about which of its rows is one; withCommanderFlag had no other caller and is gone. Not restarted: the shared checkout currently fails typecheck on another agent's untracked Minesweeper work, and a restart is when half-applied work ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
af918251d3 |
/minesweeper — a wine session streamed to the browser over RFB
a websocket byte relay onto x11vnc's tcp socket, the same shape as the terminal's relay onto the pty sidecar. this is the piece plugins/desktop could not have: a plugin cannot claim a websocket provider. the rfb client is ours rather than vendored novnc — raw and copyrect, one pixel format we dictate so a rectangle memcpys into ImageData. we control the server, so the other eight encodings are surface we would carry and never run. execution, so owner-only: wine runs as the owner, which makes the game's menus file dialogs over the owner's home. two bugs worth keeping. the token key is BEARER_TOKEN, not token, so the upgrade 401'd before any rfb byte moved. and the /status health check opened a tcp connection to prove liveness — libvncserver blocks its accept loop peeking for a websocket handshake, so a polled health check filled the backlog and locked out the real viewer. a liveness check must not be indistinguishable from a client; it reads /proc/net/tcp now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |