the .part-then-rename rule was applied at two upload routes yesterday and
by this morning the platform had a dozen other writes putting files in the
same folders — an OCR result beside its image, a TTS clip, a download
landing, a save from the editor — every one straight to the final name. a
guarantee that holds for two of fourteen paths is not a guarantee, and the
next person adding a fifteenth had nothing to copy.
so it is a function now, and every durable write goes through it: the file
browser, sendoff, task definitions, skill and tool files, bug reports,
scraped attachments, the chat provider config.
the failure it prevents is silent rather than loud. a truncated m4a decodes
fine, it is simply short — a commander transcribing recordings as they land
would produce a clean transcript of the first few seconds of a two-minute
memo and report success.
two things learned writing it. Bun.write creates the parent chain itself,
so the mkdir option i added was dead weight and several callers were doing
it twice. and Parameters<typeof Bun.write>[1] resolves to the first
overload only — BunFile — which rejects the string and Buffer every caller
actually passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
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
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
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
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
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
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
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
This is the cause. Everything before it was symptom.
`handleChat` had two spawn call sites. The first, for a new session, passed `member`.
The second — "session already live" — was a separate object literal that did not.
That branch is taken whenever OFFICER still holds the session, and officer holds it
long after the SIDECAR has let go: the sidecar reaps a session after 30 minutes idle,
`_claudeKill` stays set here regardless. So a gap sent a turn with no identity to a
sidecar with nothing to push it onto, and it created a fresh session from those
params. No member means the owner.
[claude:spawn:…] resume=49527b33… cwd=…/data/jg@pertento.ai/home as=owner
green is a Developer with their own Linux account, their own `claude` and their own
login. Every turn after a break ran as the OWNER: owner's HOME, owner's Anthropic
credential, owner's MCP config — which carries a JWT that signs as the owner. The
entire per-member boundary, undone by an omitted line in the branch nobody reads.
It surfaced as breakage rather than as silent escalation only by luck. The owner's
`claude` was handed the member's cwd — a 700 home it cannot enter — and the member's
transcript id, so it exited 1 instead of quietly working. Reported as "he went for a
walk and it stopped working", which is the idle timeout to the minute.
── The fix, and what it then found ──
Both paths now build from one `turnParams`, so they cannot drift again.
Then `member` was made REQUIRED-though-possibly-undefined in the protocol, and the
compiler immediately found three more sites that had left it out:
- `agent-handoff/deliver.ts` — the same bug, worse. No member AND a cwd from
`getOwnerHomeDir`, which ignores the email it is given. A member's agent panel ran
as the owner in the owner's home. Now resolves identity like chat does.
- `agents/agent-runner.ts`, `tasks/pipeline-executor.ts` — correct as they were:
both are `kind: 'execution'`, owner-only and never grantable. They now say
`member: undefined` explicitly, because a field you must fill in is a decision and
a field you may omit is an accident.
`resolveTurnIdentity` is exported so "whose turn is this" has one implementation
rather than three.
── What I got wrong on the way ──
Three wrong answers first — the credential, the working directory, then a recovery
whose condition could never fire — each from reading code and reasoning instead of
making the system say what it was doing. The log line that found this took five
minutes to write and should have been the first thing I did, not the fourth.
Also: `--resume` by hand appeared to work twice, which is what sent me down the wrong
paths. It proved nothing. The id I tested was not the id the platform passed.
tsgo clean, 950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ANTHROPIC_PROXY_PORT is gone. It was 5051 hardcoded in four files: proxy.ts,
which binds it, and three others that guessed the same constant to find it.
It is the only sidecar that binds a fixed port, and that part is a real
constraint rather than an oversight. Every other one binds `port: 0`, lets the
kernel choose and reports back over the registration socket — which works because
their consumer is the platform. The proxy's consumer is `claude`, spawned by a
different pm2 process that needs ANTHROPIC_BASE_URL at spawn time and has no
channel to ask what port the proxy landed on. Two processes with nothing between
them have to agree in advance.
So the number must be predictable, but it need not be 5051 — a value chosen
against nothing, in the registered range, free to collide with anything the owner
installs later. The symptom of that collision would have been chat failing while
the rest of the platform looked healthy.
PORT + 1 keeps the predictability and drops both the constant and the variable.
Nothing to set, no second number to keep in agreement with the first, and the
pair moves together when the install moves.
Also corrects .env.example, which said the proxy "holds the API credential, which
lives in the host env". It does not. The upstream credential is the OAuth token
claude writes to ~/.claude/.credentials.json, and the ANTHROPIC_API_KEY the agent
presents is the proxy's own generated secret.
Verified the derivation at PORT=9000 and PORT=10000; all four consumers now import
it; every edited file parses. Still not typechecked — empty node_modules, frozen
installs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.
It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.
It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.
It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.
server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.
Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every PORT fallback in the tree now says 9000. There were three answers to one
question, each defensible where it was written and none of them visible from the
others:
server.tsx and 19 sidecars 5000 a default from before there was an installer
user-instance.ts 9010 what scripts/setup-old/setup.sh really wrote
.env.example 9000 what we told people to write
5000 goes first because macOS binds it — AirPlay Receiver has owned it since
Monterey, so a dev server there fails to bind or gets shadowed by something that
answers.
All 22 sites moved together, which is the point. Changing the app alone would have
turned a consistent-but-wrong default into a split one: the app on 9000 while
nineteen sidecars still dialled 5000.
9010 was the interesting one. It was the only value that ever matched a real
machine, because it is what the old installer wrote — and it was in the single
file whose disagreement would have broken chat alone, with nothing else looking
wrong. Its own comment records the same bug being fixed once already, within the
file, by a change that left it disagreeing with everything outside it.
Note what these defaults actually are: the sidecars bind nothing. user-instance.ts
has no listener at all — it builds ws:// and http:// URLs that both address the
app's single listener. So every one of these numbers is a guess at where the app
is, for a value that .env always supplies. Worth removing rather than aligning,
which is a separate change.
Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; the pm2 profile loads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
host found a live regression from 95951fb, on the owner's own paths.
resolveBaseCwd used to take an email and resolve its own root. 95951fb made the
first parameter the home itself, and three callers outside that diff kept
passing an email: pipeline-executor twice and agent-handoff once. Both
parameters are string, so tsgo had nothing to say. Every pipeline step and
handoff with a relative cwd, a `~`, or no cwd was building a path out of an
address, resolving it against the platform's own working directory — the
checkout. Absolute paths kept working, which is what would have made it look
intermittent.
All three are owner-only, so they now pass getOwnerHomeDir(email) explicitly,
the way agent-runner does. The definition of resolveBaseCwd carries the warning:
an absolute path, NOT an email, with the reason.
Not done: the branded type this argues for. Two strings meaning "identity" and
"filesystem path" sat adjacent through a refactor and the compiler could not
help, which is a real gap — but it reaches every path function in the server,
and doing it at 01:00 on the back of a bug caused by a hasty refactor would be
the joke telling itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer's registration socket silently drops sends when it isn't OPEN
(sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed
raw parser events over that socket and officer translated and persisted them, so
everything a turn produced while officer was restarting went nowhere: the turn kept
running, the output was gone, and a reconnecting client replayed a log that simply
had no rows for those seconds. stage 1 kept the agent alive across a restart; this
is what makes its output survive one too.
move the translation and the write into the sidecar:
- turn-stream.ts is the stateful ChatEvent -> browser-message translator lifted out
of websocket.ts (delta buffering, flush before tool:start and result). pure and
synchronous, so it is unit tested — 12 tests, 100% lines.
- session-log.ts commits each message to chat_session_events and only then hands it
to officer, with its cursor id attached. per-session promise chain: translation is
synchronous and therefore in arrival order, and only the commit is queued, so
cursor ids are assigned in the order events actually happened. a delta that
overtook the assistant:text in front of it would make the client commit its stream
buffer at the wrong point, so deltas go through the same queue even though they are
never written.
- claude:event on the wire becomes claude:message: a finished browser-facing message
plus its seq. officer relays it verbatim and folds it into the in-memory session
for sync:messages. it no longer builds or persists chat messages for this harness.
gap detection, which is what the durable log is for. chat_session_events.id is a
global bigserial, so two consecutive events of one session are not consecutive ids
and a client cannot tell a contiguous replay from one with a hole in it. each durable
message now carries prevSeq — the cursor of the previous message in the same session —
which is inside the persisted payload, so it survives replay. useChat compares it
against the cursor it holds before advancing, and surfaces a visible marker on a
mismatch: a conversation that silently skips a tool call or half an answer reads as
the assistant having done something inexplicable. only checked once a cursor exists,
because opening a session from history legitimately starts mid-chain (events are swept
after 7 days, the transcript is not).
a failed write delivers live with no seq, so the client sees the message but does not
advance past something it cannot replay, and the next successful write chains from the
cursor the client still holds.
pipeline steps pass durable: false. their sessionKey is a throwaway uuid no browser
will ever replay and the job's own event log is its record, so writing those rows only
grows the table.
opencode still goes through officer's createEventHandler, now labelled as such. that
is the sidecars-opencode branch.
this fixes R4 from CLAUDE_SIDECAR_ISOLATION.md. R3 and R5 already worked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
move video/audio downloads off the dedicated ReClip download lane onto the
generic script-job path:
- delete execute-download.ts, reclip-client.ts and the POST /jobs/download
endpoint; drop the 'download' mode from the pipeline_jobs enum (legacy rows
tolerated)
- execute-script.ts: strip the @@officer:progress@@ sentinel from the log,
emit progress events, and isolate viewer/log writes (safeEmit/safeLog) so a
broadcast or log throw can't wedge the stdout pump
- pipeline-job-manager.ts: persist latest progress; guard sendToViewer sends
- ScriptJobDetail: render the two progress bars; DownloadJobDetail kept for
legacy history rows
- TaskRunnerModal: ScriptRunner descends into the triggered directory
- VideoDownloadPanel: rewire startJob to the script-job path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed download has no media file, so its description was lost — no way to tell
which video was missed. Now a failure writes a `<title>.txt` (or `<videoId>.txt`
when untitled) holding the URL + description, so every missed item leaves a
recoverable reference. Always written (even with an empty description — the URL is
the reference); skipped only on a deliberate Stop, not a genuine error.
Verified: successful item → "<media base>.txt" (description); failed item →
"<title>.txt" (url + description); spaces preserved in both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The metadata phase already fetches ReClip's description (ReClip now forwards it in
/api/info). Carry it into the download phase and write it to a text file with the
same base name as the media — "Song Name.mp3" → "Song Name.txt".
reclipDownloadOne gains an onFilename callback that fires the moment the final
filename is known (before the file transfers), so the executor writes the sidecar
in parallel with the download stream, and the exact name guarantees they pair up.
Empty descriptions write nothing; the write is best-effort (never fails a download).
Verified: correct base name + .txt, exact content, and no sidecar for an empty
description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against live ReClip: POST /api/download with title:"" → a hash filename
(b5d04adc86.mp3); with title:"Me at the zoo" → "Me at the zoo.mp3". So ReClip
names the file from the title WE send (falling back to a hash) — it does not
self-name. The title is mandatory, which means a metadata pass is required.
Back to two phases:
1. metadata — fetch each item's /api/info (title + validity), keep survivors, skip
errors.
2. download — download each survivor passing its title, so files land with real
names; skip download errors.
Keeps the exact-urls[] input (Mix playlists can't drift) and the one-request-per-
item download. Progress is two counters again (Titles + Download); UI shows two
bars. ~2 requests/item is inherent to needing the title (per the user's call:
correctness over speed).
Verified two-phase filtering + title passthrough + skip-on-error with a mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A YouTube Mix/radio playlist (list=RD…) returns a different set of items on every
/api/playlist call (observed 779 / 1485 / 529 for the same URL). The job used to
re-expand the playlist server-side, so it would download a different list than the
count shown on the decision screen.
The panel now passes the already-expanded `urls[]` into the job, and the executor
uses them verbatim (falling back to expanding `url` only when no list is given).
The job downloads exactly what you decided on. Endpoint takes `urls[]` (stored as
inputs.urls) or `url`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The job's two phases were a misread — the "count" phase is the client-side
playlist expansion (for the inline-vs-job decision, already done in the panel).
The job itself is just one download request per item.
Dropped the in-job metadata pass entirely:
- reclip-client: reclipDownloadOne no longer prefetches /api/info for a title —
ReClip names the file from the video title itself, so it's a single request
per item.
- execute-download: one phase — expand the playlist, then /api/download each url,
skip failures. Progress is a single { done, failed, total, current } counter
(no meta/dl split); ~2× faster and downloads start right after expansion.
- UI (DownloadJobDetail + panel JobView): one "Downloaded" bar instead of two.
Verified: every item is attempted directly (no /api/info gate), skip-on-error
counts correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Front half of the download-job feature.
Panel (VideoDownloadPanel): after Fetch expands a playlist and the count is known,
a decision screen — "Found N items" → pick Audio/Video + subfolder → "Download all
as a job" (POST /jobs/download), or "fetch inline to pick individually" (the
existing card grid). A single video still goes straight to the inline card. The
job phase shows live two-phase progress (polled from the job) + a "View in Jobs"
link; it notes the job runs server-side so closing the panel is fine, and it
refreshes the browser as each file lands.
/jobs (DownloadJobDetail + JobsPage dispatch): a `download` job renders a compact
two-phase readout — Metadata and Download bars (processed/total, found/skipped and
saved/failed) + the current item — polled from the job's progress, with a Stop.
Executor tweak: phase-1 meta.done now counts kept (not processed) so both phases
read the same `(done+failed)/total`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns the downloader into a server-side job on the existing jobs spine (Postgres
persistence, live WS viewers + replay, abort, /jobs UI) — but with its own
executor and its own lane, since it's deterministic scripting, not an agent, and
a multi-hour playlist mustn't block agentic jobs.
- reclip-client.ts (new, shared): reclipInfo / reclipPlaylist / reclipDownloadOne
(single download → streams the file to a dir, abort-aware). Extracted so both
the file-browser endpoints and the job executor use one client.
- execute-download.ts (new): the two-phase executor —
phase 1 metadata (expand playlist, fetch each info, keep survivors, skip
errors), phase 2 download (each survivor in the chosen format; skip download
errors). Emits a compact `download:progress` snapshot (counters, not per-item
events — playlists are thousands of items). Throws on abort / fatal.
- job manager: `download` mode dispatch → executeDownload; persists
download:progress; adds execution LANES (download vs default) so the two run
independently and each serializes on its own; promoteNext fills both lanes.
- POST /api/tasks/jobs/download { url, format, dir, root?, label? } — enqueues a
download job (own lane, no capability task needed; traversal-guarded target).
- schema: `download` added to the mode enum (drizzle text-enum — no DB migration);
getPendingJobs() query for lane filling.
Verified the executor with a mocked ReClip client: two-phase filtering, skip-on-
error counts, and abort-throws all correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three changes that Transcribe Audio needs.
Whisper's translate mode only ever outputs English, so it cannot honour
"translate into <language>" for anything else — it answered Portuguese audio with
a rough English rendering instead of a transcript. The translate decision is gone
and transcription is always faithful to the detected language; spokenLanguages
now only breaks ties on clips Whisper is unsure about.
Script tasks get OFFICER_API_URL / OFFICER_API_HOST / OFFICER_AUTH_TOKEN so they
can call Officer's own endpoints rather than reimplementing server-side work.
Requests go to 127.0.0.1 so nothing depends on DNS or the proxy, but origin
validation matches Host against PUBLIC_URL, hence the separate host variable.
`inline` accepts "ask", which offers both affordances in the runner — Run here
streams into the modal, Run as job queues it. Useful when the same task can take
a second or an hour depending on whether it was pointed at a file or a library.
Existing true/false values behave exactly as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CATEGORY_ORDER hardcoded Video/Audio/Cleanup in the frontend, so adding a
category meant a code change. The order now lives in categories.yaml at the root
of the items store and reaches the client via GET /tasks/categories — the
platform no longer knows any category by name.
The endpoint is declared before /:name, which would otherwise match
"categories". Categories used by a task but absent from the file still work: they
sort alphabetically after the listed ones, and Other stays last.
Menus consume grouped tasks rather than grouping them per row. Groups are built
from the tasks and the file only ranks them, so a category listed with no
matching tasks cannot produce an empty submenu — locked in by tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every task declares a directory trigger, so right-clicking any folder listed all
fifteen at once — Tag Album offered on a folder of photos. TASK.md gains an
optional `category`, and the context menu nests by it: Run Task > Video > …
Nesting only kicks in when more than one category matches. A .mp4 matches eight
tasks that are all Video, so file menus stay flat rather than gaining a pointless
hop. Known categories lead (Video, Audio, Cleanup); anything else follows
alphabetically with Other last.
Applied to both menus — the right-click one and the ⋮ dropdown — which carried
identical blocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer is single-user: the server owner is the only account, created once by
/auth/bootstrap. Everything that existed to serve additional users was
unreachable, so it is gone rather than left looking like it does something.
Accounts: drop the invite / resend-invite / delete / list-users routes and the
Users settings screen, the inert /auth/signup handler, and the account
verification chain it fed (verify, resend-verification, VerifyScreen, the
UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token
survives for password resets only, and now requires a reset-password token
rather than accepting any signed JWT.
Roles: drop the users.role column and the four-value USER_ROLES enum. The
permissions table granted every role identical methods, and every
role === 'Super Admin' check was permanently true. The JWT no longer carries a
role claim.
Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected
only for non-Super-Admin users, so it never ran. It was also not a usable agent
jail as written — --share-net, the project root (with .env) bound read-only,
and runuser dropping to the server's own uid. Rebuilding it for agent
containment would be a different construction, and git history keeps this one.
getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the
owner's real login home, which is what terminals, chats and task runs use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1 of removing Pi (Claude-only). Cuts the non-Claude branches in the chat
WS handler, pipeline executor, and channel send-and-await; deletes the Pi
sidecar, its ecosystem entry, pi-bridge, and the Pi model-listing spawn (now a
static Claude tier list). Adds a guard coercing any legacy non-claude-code model
preference to the Claude default so old settings don't break chat or jobs.
Removes the dead no-op session-save REST route and stale Pi docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
a script task that goes silent for a while (e.g. ffmpeg's faststart pass rewrites
a huge file for minutes with no output) would hit Bun's default 120s websocket
idle timeout → close(ws) → killTree killed the task mid-run, corrupting the
output. now the executor pings the socket every 30s while a task runs, resetting
the idle timer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
proc.kill() only signalled the direct child (bash), leaving a running ffmpeg
grandchild orphaned and still encoding. now killTree() walks /proc, SIGTERMs the
whole subtree (bash + ffmpeg), then SIGKILLs any straggler after 2s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the marketplace service dependency and the native/global/user
scope tiers with a single external directory ($OFFICER_ITEMS_DIR) holding
skills, tools, tasks, processes and extensions as plain files.
- tasks move from Postgres to TASK.md files (new file-backed task layer);
task editing now works, which the DB path never supported
- skills/tools/processes collapse into one shared file router (single dir)
- remove the marketplace client (sync-marketplace/sync-version) and the
boot-time sync; pi-bridge/pi-manager/sandbox point at the flat store
- drop the dead tasks + vestigial skills/tools/processes/extensions +
item_chats tables (migration 0004)
- one-time migration script exports DB tasks and consolidates disk items
Migration verified: all 6 tasks round-trip through the runtime parser
identically to their DB rows (pipeline steps, triggers, script impls and
agentic bodies all intact).
NOTE: not yet functionally tested end-to-end — every item (each task mode,
tool, skill, extension) still needs to be run/exercised in the app before
this is trusted. To be done manually.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the file browser sends msg.cwd relative to the user's home and it went straight to
Bun.spawn, which resolves it against the server's cwd. when that directory does not
exist posix_spawn reports ENOENT naming the binary rather than the directory, so
"Convert Audio" failed with the misleading "posix_spawn 'bash'".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Agentic steps now have a 5-minute inactivity timeout (non-Claude-Code
models only) so stalled local LLM requests fail with a clear error
instead of hanging forever. A "Waiting for model" indicator is emitted
every 10s and shown in the pipeline UI stats bar, clearing as soon as
the model starts responding.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix stop: abort poll now rejects the promise after killing the agent process
- Fix jobs link: close modal before navigating, use react-router navigate
- Fix seed parser: strip quotes from step input values (delete_source: "true")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Pipeline jobs now persist to DB with progress tracking and cost accumulation
- Jobs survive WebSocket disconnects with in-memory event buffer replay
- New /jobs list page with search, status badges, and cost display
- New /jobs/:id detail page with live WebSocket attachment and REST fallback
- Two-column layout using WorkspaceLayout for resizable steps/output panels
- Streaming messages tagged with stepIndex/iterationLabel for per-step output grouping
- TaskRunnerModal links to job detail page once job is created
- Dock entry added for Jobs page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Pipeline mode: new task mode that chains agentic tasks sequentially with
foreach/subdirectory iteration and skip_if conditions
- Pipeline executor backend (WebSocket at /api/tasks/pipeline/ws) with
support for both Pi and Claude Code models
- Frontend PipelineRunner component with step progress, streaming output,
and aggregate cost tracking
- New agentic tasks: prepare-discography, fetch-album-info, build-discography
(pipeline combining both)
- Seed parser extended to handle pipeline steps in frontmatter config
- CopyButton component added to assistant bubbles, error bubbles, and
tool input/output sections
- Removed obsolete SearXNG/Apify/browser relay code from pi-bridge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks now live in the database (mode: script or agentic). Script-mode tasks
bypass the agent entirely — the implementation is materialized to a temp file
and executed directly, with stdout/stderr streamed to the UI via WebSocket.
Includes convert-to-mp3 as the first native script task.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- apify tool: TOOL.md definition, index.ts implementation with auto-auth via OFFICER_APIFY_TOKEN, output_path for large datasets
- tools API: /tools routes (list, detail, chat, create, delete) mirroring tasks pattern
- automation UI: tools tab in sidebar, NewTool component, tool detail view
- apify integration: settings page for enterprise API key config, pi-bridge passes env var to containers
- tiktok-trends task: rewritten as agent instructions using apify tool with output_path, scripted report generation for 50KB read limit
- restrict edit/delete of native/global capabilities to Super Admin only (backend + frontend)
- tools authoring guide: TOOLS.md with full spec for TOOL.md frontmatter, index.ts execute signature, patterns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>