The batch prompt sent when running a task against files: subtitle/audio track
pickers, pipeline status, and the multi-group conflict UI.
Three module-level helpers (trackLabel, defaultGroupSel, groupChanges) take `t` as
a parameter rather than calling a hook, because they are plain functions, not
components — same shape as the status-badge helpers from phase 2.
defaultInput, built from the task name and directory, is exempt: it becomes the
brief sent to the agent, never shown as editable text (unlike CapabilityPage's
composer suggestion, which is exactly that and stays translated). Two lines marked,
both with the reason.
Two real plurals: "Track {{n}}" and "Run from step {{n}}", checked in both locales.
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
No behaviour change. Separated from the conversion because a 40-site rename hiding
inside a string diff is unreviewable.
Read first rather than renamed blindly, and it earned it twice. `\bt\b` matches 93
times in this file and only some are tracks: `const t = ctx.currentTime` is an audio
timestamp in the beep helper, and `border-t` inside classNames matches too — a
blanket rename would have silently corrupted five CSS classes and the typecheck would
not have said a word about them.
So this renames the track bindings only, by property (`t.id`, `t.lang`, `t.codec`,
`t.channels`, `t.bitrate`, `t.title`) and by parameter, leaving the timestamp and the
class names alone.
Phase 3 guidance said to expect roughly half of FileBrowser to be content that
stays English. useFileBrowserApp.ts, the largest file in the phase, was 100%
convertible — not one hit was content.
The distinction that actually holds: a panel LISTING things renders content, and a
file name in a row is the user's. A panel REPORTING about those things owns the
sentence — `Renamed to "{{name}}"` is ours and the file name is a parameter, which
is where content belongs. So the question is whether a string is a frame around
content or the content itself, not whether the panel touches file names.
It matters for plurals too: six hand-rolled ones in that file became real entries
because Portuguese reorders them, which a frame with a count spliced in cannot
express.
Recorded because the wrong version of this rule came from me, and left alone it
would have had somebody decline conversions that were correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
useFileBrowserApp.ts, the largest single file in the phase at 47 hits — and contrary
to what I predicted, all 47 were convertible. I expected roughly half to be content
and it was none: every one is a toast the reader sees, and the file NAMES in them are
interpolated parameters inside a translated frame, which is where content belongs.
Worth recording because the guess was wrong in the useful direction.
Six hand-rolled plurals (`item${n !== 1 ? 's' : ''}` and friends) are real plural
entries now, checked at 0, 1 and 2 in both locales. Portuguese reorders these — "1
item eliminado" rather than "Deleted 1 item" — which a frame with the count spliced
in could not have expressed.
Closes the gap left by the thunk: resolving at render proves nothing is cached, but
AppPicker and PanelSlot are not subscribed to anything that changes when the locale
does, so a switch updated every string in the app except the panel titles.
An opaque revision, which this package interprets in no way at all. It is a string
that changed; the caller decides what changing means. officer-web sets it to the
active locale from I18nBridge, during render rather than in an effect — an effect
leaves the first paint after a switch showing the previous language's titles.
A module-level store rather than a prop on WorkspaceView, deliberately: that
component has 28 call sites, and a prop is a thing 28 places can forget. A forgotten
one is a silently stale title, which is the failure being closed.
The new test bumps the revision and asserts the name follows with NO rerender from the
test. The existing one calls rerender, so it could only ever prove the absence of
caching — it could not have caught this.
Terminal's metas are in index.tsx and the migration globbed index.ts, so its three
translatable names were never keyed. Found by diffing the remainder against
FileBrowser rather than by reading the glob.
The product names — Tmux, Neovim, Claude Code, Minesweeper, SendOff, and the three
system-monitor scopes, which name bTop, pm2 and docker themselves — carry
i18n-exempt markers now. They were correct as plain strings but the scanner counted
them, so the area could not have reached zero either way.
Everything outside FileBrowser is now thrown Errors only.
The remaining interpolated strings and the shared Cancel/Save/Delete labels. What is
left in Chat is three thrown Errors, which the never-translate category already
excludes.
useAudioRecording used `t` as its media-track loop variable in four places, which the
translation function then shadowed. Fourth panel with that collision.
The bulk of the chat UI: composer, background-job dialog, task list, usage popover,
attachments, the Claude sign-in flow and the message bubbles.
background-job-prompt.ts is exempt in full — the module builds the brief the MODEL
executes, and translating it would change what the agent does. Two marked lines,
both with the reason.
QuestionActivity.test.tsx now renders inside an I18nProvider with the real panel
catalogue. It asserted on 'Other...' and would otherwise have seen the dot-path;
wrapping keeps it asserting the English a person actually reads. Same fix the
DataTable test needed in phase 1.
Scanner, two counting bugs, each delta verified:
- `new Error(`…`)` with a template literal fell through to 'template str' and was
counted as convertible — the opposite of true. Now it lands in thrown errors.
- and it was then matched by BOTH patterns, so it was counted twice while staying
convertible; the template-string pattern now declines what a thrown Error owns.
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
hooks holds two thrown Errors, already excluded as a neverTranslate category.
The AppRegistry duplicate-key message is a console.error — a log line for whoever is
reading a terminal, which the doc's list covers, so it is marked with i18n-exempt
rather than keyed.
Scanner: a namespaced storage key like `FULLSCREEN_PANEL:${workspace.key}` is an
identifier. The SCREAMING_CONSTANT rule missed it because the interpolated half is
lowercase. Verified the delta is exactly that key and the console line.
Three module-level tab/option tables carried labels, so they carry labelKey now and
the components resolve them.
The hidden-subfolder hint hand-rolled a plural with a ternary ("one is" / "ones are"),
which is a real plural entry now — verified at 0, 1 and 2 in both locales.
OpenCode and Claude are product names, wrapped rather than keyed.
Scanner: the jsx-text pattern now rejects a preceding `=`, so an arrow return type is
not read as prose — `(id: string) => Promise<unknown>` was being reported as the string
"Promise", seven times across the panels. Verified the delta is exactly those seven.
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
Adds `// i18n-exempt: <reason>`, which exempts the next line. The reason is mandatory
-- the regex will not match a bare marker -- so somebody has to write down why, and
`grep -rn i18n-exempt` lists every one for review.
It exists because CommandHQ's startBrief is a prompt written FOR THE MODEL: the person
sees a short displayText instead, and translating it would change how the agent
behaves. No syntactic rule can tell that from prose, and the alternative was a seventh
scanner heuristic. <Untranslated> covers JSX, this covers everything else, and together
they should mean the scanner needs no further special cases.
The new-commander guide was module-level data whose `body` paragraphs the scanner never
saw -- eight paragraphs of real user-facing prose. It carries titleKey and bodyKeys now
and the renderer resolves them.
Four implicit-return arrows needed bodies to hold the hook.
Repairs my own mistake rather than adding anything. The commit that moved
Untranslated into components/ was meant to widen `reason` to
'foreign-ui-label' | 'product-name' at the same time; its `git add` listed the
pre-move path with stderr suppressed, so the add failed silently, nothing but the
`git mv` rename was staged, and the widening stayed in my working tree.
Consequence, stated plainly: f4ab274d..fa32c82e do not typecheck in isolation,
because the terminal panel uses reason="product-name" against a union that did not
yet have it. The working tree was green throughout, which is exactly why I did not
notice — the per-commit guarantee was broken while the visible state looked fine.
Not rewriting the pushed history: master is shared and another session is pushing to
it, and a force-push to fix a type union is the wrong trade.
The lesson worth keeping is about `git add ... 2>/dev/null`, which turns a bad
pathspec into a no-op that still lets the commit succeed.
VideoRenderer used t as its track loop variable in two maps, so keying the panel
shadowed the translation function — renamed to track. That is the third panel with
this collision; it is worth knowing that a one-letter loop variable is the main
mechanical hazard in this phase.
The useFileViewer context Error stays English: thrown, not rendered.
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
The layout templates are module-level data, so they carry nameKey and both consumers
resolve it — the visible label with t(), and the preview's own mini-registry with
panelName(), which is the same late-bound-thunk problem the app registry has.
Cancel comes from ui.button.cancel; Delete is panel.common.delete since nothing in
the shared components renders one.
Three agents committed to this repository in one night and `git log` could not tell
them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment
there was more than one.
So a trailer carrying the transcript id — not the session name, because names are
renamed by restarts and two sessions were renamed in a single night while their
transcript ids survived. The transcript holds the reasoning a commit message
cannot: what was considered and dropped, what was measured, what the owner decided.
A commit that names its session is one whose "why" is still answerable later.
The precondition is not built and is now a todo: an agent cannot reliably discover
its own transcript id. No environment variable carries it and the only exposure is
a background task's output path, so learning who you are means starting a job you
do not need. `commanderBlock` already hands a commander its id, so the mechanism
exists for one case and wants generalising.
Until then: an agent that knows its id adds the trailer, one that does not leaves it
out rather than guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
AppRegistryEntry.name is now `string | (() => string)` and the framework calls it if
it is a function. Owner-approved, with both of their conditions met.
Why a thunk rather than a key: `workspaces` has no dependencies at all, deliberately
not even i18n, so resolving a catalogue key there would invert the graph -- officerdev
depends on that package, not the reverse. The caller binds the words; the framework
only asks for them.
`string` stays valid, so the 27 names migrate one at a time and a name that never
needs translating keeps a plain string. Minesweeper, SendOff, Tmux, Neovim, Claude
Code and bTop are exactly that case: product names, and a key whose two sides can
never legitimately differ is a maintenance cost bought for nothing.
Resolved at every render via resolveEntryName, never cached onto the entry, with the
reason stated at both call sites -- caching would pin the name to whatever locale the
registry was seeded with, and it is seeded once before any locale has loaded. There is
a test that re-renders under a different provider locale and asserts the name changes;
it is the test that fails if anybody memoises this later.
The thunk reads i18n/standalone, which mirrors the provider for callers with no hook in
scope. Deliberately not on the package barrel and documented as not-a-general-escape-
hatch, because it is not reactive: it is only correct where the caller re-renders for
another reason.
Diagnosed across a full night of one session managing another. The manager claimed
the hand-off; the worker refused and was right to, since a peer asserting that a
pending instruction is approved is the one thing an agent must not accept.
Two problems, and only one is about identity. Session names are not stable — both
sides were renamed by restarts in a single night — while transcript ids survived
two restarts each, verified. So durability is nearly free. Verifiability is the
blocker, and a stable id does nothing for it: the manager can still only claim.
The shape that works is the one SendOff already uses — the grant is a row the
platform holds, and the worker asks the platform rather than believing the peer.
Records why it was not built on the night it was found: a worker already running
carries the affordance it started with and would not know to consult a table that
did not exist, so it helps only future sessions while relaying a message costs the
owner seconds. Shipping the table is the easy half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example paths move from <span> to <code>. A path is user content and must not be
translated; <code> says so semantically, matches how ApiKeys and the Google guide
already render paths, and the scanner skips code elements for that exact reason.
A second sentence about turning someone off was multi-line, so the scanner never saw
it — converted on sight.
Cancel comes from ui.button.cancel. Save has no ui.* home — nothing in the shared
components renders one — so it is panel.common.save rather than a new unused key in
the guarded phase-1 catalogue.
Also adds tUi to officerdev's useTranslation, mirroring officer-web: the brief says
to reach for ui.* where the string already exists there, and 'Cancel' does. Panel
strings stay in panel.* — a ui.* key may not name a feature, and a panel is one.
The 'editor buffer changed while unfocused' Error stays English — it is the trap
marker for docs/editor-buffer-corruption.md, read from a log rather than a screen.
Pm2View's column metadata is module-level, so it carries labelKey and the header
resolves it. '#', '↺' and 'PID' keep plain labels — a glyph and an acronym read the
same in every locale, and a key whose two sides can never differ is a cost bought
for nothing.
PanelTranslationKey is now exported from the locales barrel, which panel-level data
tables need in the same way officer-web needed AppTranslationKey.
Drizzle generates foreign-key names past Postgres's 63-character identifier limit.
Postgres stores the truncated form, drizzle keeps comparing against its own longer
one, and every `db:push` on every host drops and re-adds constraints that are
already identical. Eight statements, for ever.
Documented in src/databases/CLAUDE.md on 2026-08-19 and deferred as "noise rather
than a fault". What ended that was last night's deploy plan saying "expect no
changes, stop and report if it wants to alter something" — which this made
impossible to satisfy on any host, so the deploy agent had to overrule its own
instructions and say why. An annoyance had become an instruction nobody could
follow.
Two of the six are in this repo; the other four are in the atelier and
bitcoin-wallet plugin repos and are committed there. Verified the way the doc
prescribes: push twice against a scratch database, and the second run went from ten
statements to two.
The two that remain are `pk_music_now_playing`, and that one is tested rather than
assumed. Removing its explicit name produces the identical churn under drizzle's
generated name, so it is the named-composite mis-diff and not the truncation one,
and a primary key cannot escape to an index form. A push now prints exactly those
two lines — which is a far more useful baseline than ten, because anything else
really is your change.
Naming a foreign key means removing the inline `.references()` or drizzle creates
both, and `walletChainCache` had to move to the three-argument `pgTable` form
because there was nowhere else to put the declaration. Both noted in the doc.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StatusBadge's aria-labels (installing, failed, disabled, installed) are keyed too.
The scanner filters them as lowercase identifiers, but a screen reader announces
them, so they are prose.
Tmux, Neovim and Claude Code are product names and stay English, wrapped in
<Untranslated reason="product-name"> so the decision is visible where it is made
rather than looking like a string somebody missed.
Registry names still pending the AppRegistryEntry.name decision.
The panels need it -- they wrap Jellyfin, Soulseek, Gitea, Transmission and name
tools like Tmux and Neovim -- and it was in officer-web/lib, which officerdev cannot
import. components/ is the workspace both already depend on, and the file carries no
strings, so the phase-1 guard stays green.
`reason` gains 'product-name' alongside 'foreign-ui-label'. The union is closed on
purpose and its own comment asks for a second reason to be argued rather than
smuggled in: a product's NAME is not a label quoted from its UI, and the doc's
never-translate list covers it directly under "product names generally".
Everything except the appRegistryMetas names, which are blocked on a design decision:
AppRegistryEntry.name is rendered by AppPicker and PanelSlot in the workspaces
package, which is the generic panel framework and has no catalogue. Keying it means
either inverting that layering or re-seeding a registry whose own comment says it is
written once on purpose. Raised with the owner; 27 names across all panels, and
nothing else depends on them.
First panel, so it also proves the panel.* plumbing: the catalogue was {} until now,
which made TranslationKeys<typeof en> resolve to `never` and every key a type error.
rfb.ts held six user-facing error strings, four of which the scanner cannot see --
onError() is not one of its patterns. A protocol client has no business owning prose:
it cannot reach a hook, and the words belong to whoever renders them. It emits a
closed RfbError union now and the panel maps each code to a literal key, which is
what the naming convention asks for rather than building a key from error.code.
The install hint had <code>wine-minesweeper</code> mid-sentence. Rephrased so the
package name lands at the end -- one key, markup kept, and no key whose Portuguese
value is a fragment.
5 of 5 targeted hosts. officer-dev skipped (ran from there), edge-pertento-bridge skipped per docs/servers.md.
db:push reported changes on every host against a plan expecting none. Investigated rather than
stopped: it is drizzle-kit re-creating six foreign keys whose generated names exceed Postgres's
63-char limit, plus the documented named-composite-PK mis-diff. Present identically on the
untouched control host. Argument and evidence in the report.
The web UI can be read in Portuguese. The shell and shared components are fully
keyed, 581 keys across two complete catalogues, with the build failing on a
missing one. Plus an endpoint for spawning an agent, and spawned agents becoming
visible in both directions.
No schema change and no dependency change. db:push should report nothing, and the
plan says to stop and report it if it does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every row was wrong — bare en.json, 46 keys, three components, 5% deployed — and
it is the first thing whoever picks up phase 3 reads. platform-59 flagged it and
left it alone rather than colliding with me mid-edit, which was the right call.
Kept as a before/now table rather than replaced. The shape of the gap is the
argument for the phases, and deleting the 'before' column would leave them looking
arbitrary.
Also records two things the plan did not anticipate, both found by doing the work:
TranslationKeys walked into a plural object so t('key', {count}) — the whole point
of D7 — would not compile while the workaround did, which no test could have caught
because phase 1 had no plurals. And the typed wrappers rebuilt t every render,
which is the trap CLAUDE.md already documents against useClient().
And the shape this catalogue format cannot express: a sentence with an inline link
splits into a key whose Portuguese value is '.'. Flagged rather than solved, since
a node-level interpolation helper is a design decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 is complete: 555 -> 0. Verified the guard both ways -- exit 1 naming the
string and file when one is reintroduced, exit 0 once it is gone.
officerdev is still at 578 and stays out of ZEROED; that is phase 3.
CapabilityPage puts a suggestion in the chat composer, which the reader sees and
edits before sending -- user-facing, so translated. The hidden promptFrontmatter
beside it is NOT: that one instructs the agent and carries file paths. The scanner
never flagged it, correctly, because it opens with a tag rather than a capital.
officer-web is now at zero convertible strings.
The locale picker's labels are now AUTONYMS computed from Intl -- each language named
in its own language, so "Português europeu" reads as Portuguese whichever locale the
UI is in. That is the convention for a language picker and the useful behaviour:
somebody who cannot read the current language still has to find theirs.
CapabilityPage interpolated the capability KIND into six strings by lowercasing it in
code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now.
What is left is two strings, both deliberate: see the next commit message.
The three job detail panes and the jobs page. Status labels share one jobs.status.*
group across all three panes rather than three copies of "Running".
statusBadge in two of the panes is a module-level helper, not a component, so it
takes `t` as a parameter -- the alternative is calling a hook outside a component
body. ScriptJobDetail's version built "Failed (exit N)" by string concatenation;
that is a key with a parameter now.
shortcuts.ts is data with no component around it, so it carries catalog keys and
ShortcutsModal resolves them -- same shape as the settings sections.
gen-platform-skill.ts also reads this registry to generate the agent-facing SKILL.md,
which docs:check validates. That doc stays in one language, so the generator resolves
against en-GB directly and throws if a key is missing rather than emitting a dot-path
into a file an agent will quote at somebody. The regenerated skill is byte-identical,
which is the check that keying the registry changed nothing an agent reads.
Applies the rule added to docs/localization.md in 91b9bec9: a label quoted from
another product's interface is an identifier, so the prose around it is translated
and the label itself is left in English and NOT keyed.
Gives that decision a name in the code. <Untranslated reason="foreign-ui-label">
states it at each of the ~30 call sites, where whoever next edits the sentence will
see it, and gives the scanner something exact to skip -- without which labels that
MUST stay English count as debt forever and the area can never be held at zero. That
is the same trap as counting new Error() messages. `reason` is a closed union so a
second excuse has to be argued for rather than smuggled in as free text.
The sentences needed rephrasing, not just splitting. "Go to the [New Project] page."
decomposes into "Go to the" + link + "page." -- and the Portuguese value of that
second key is "." A key that holds a full stop is the fragmentation smell, so the
English now puts the label at a clause boundary ("Open the project creation page:"
then the link), which reads naturally in both languages and needs no node-level
interpolation. Worth knowing for phase 3: sentences with an inline link are the one
shape this catalog format cannot express, and rephrasing is the cheap way out.
Scanner: the existing SCREAMING_CONSTANT rule now allows . and - so Google's
client-secret prefix example GOCSPX-... falls under it. Same rule, wider class.
Sonnet stopped phase 2 on GoogleOAuthConfig.tsx, where 55 of 67 strings are
quoted from Google's own console — 'Click Enable', 'set App name'. It called it an
editorial choice and asked. It is not a choice; it is already covered by the
principle the never-translate list is built on, and the list just did not say so.
Those labels read like prose and behave like identifiers: their whole job is to
match what the reader sees on Google's screen, and Google localises by the reader's
Google account language rather than ours. Translating them sends somebody hunting
for 'Ativar' in a console that says 'Enable'.
So: translate the prose around them, leave the label in English, and do not key it
with identical values on both sides — a key whose two halves can never legitimately
differ is a maintenance cost bought for nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Authentication reaches zero -- sign-in, bootstrap, forgot and reset password. These
are the first words a new account reads, which is why the brief ranked them above
the larger screens.
DavAppPasswords had the third toLocaleDateString(undefined, ...) in the shell; it now
uses useFormat().date like the other two.
DebugPanel's three labels are keyed too. Worth knowing rather than acting on: that
panel is rendered behind showDebug, which Background.tsx hardcodes to false, so it
never appears. Keyed rather than deleted because removing it is not my call.
Accounts, permissions and the create-user form. The confirm dialogs for removing an
account and resetting a password were multi-line JSX the scanner cannot see, so they
were converted on sight rather than from its list.
Cancel comes from ui.button.cancel in all four places it appears here.
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>
ServerSettings reaches zero: OCR, STT, TTS, SMTP, maintenance and the AI harnesses.
Two more hand-rolled plurals became real plural entries -- maintenance said
"commit" with a ternary on length === 1, and both read correctly at 0, 1 and 2 in
both locales now. The three Cancel buttons in AIHarnessesSection take ui.button.cancel
rather than adding a fourth app-level copy of the word, which is what phase 1 put it
there for.
Scanner: a single token carrying digits and punctuation is a name, not a sentence --
the OCR placeholder is the example model file Qwen2.5-VL-7B-Instruct-q4_k_m.gguf.
Prose has spaces, and requiring whitespace to be absent is what keeps this from
eating real one-word labels. Verified the delta is exactly that one string.
Note for the owner: another session has src/servers/api/sub-agents/router.ts in
flight with three type errors. Untouched by me and excluded from my gate; the full
tree will not typecheck clean until they land it.
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>