Commit Graph
216 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 764ba23206 command hq: per-turn cost ledger, watcher fires as their own table
The owner asked for individual turn token/dollar costs, a running total, cost
over time, and cost per trigger — derived, not stored, following the same
rule `running` already uses. Two new tables carry it:

- command_hq_watcher_fires: one row per trigger (sessionRowId, taskId,
  firedAt). Replaces the single watcherLastFiredAt column, which lost the
  previous fire's timestamp every time a new one landed — fine for "when did
  it last fire", useless for "how many times, and what did each one cost".
- command_hq_turns: one row per completed turn (sessionRowId, fireId
  nullable, inputTokens, outputTokens, totalUsd). Nothing pre-summed —
  running total, cost-over-time and cost-per-trigger are all queries over
  this table. Turn count needed no column either: COUNT(*) already answers
  it, confirmed explicitly with the owner rather than assumed.

command_hq_sessions gains watcherArmedAt/watcherStoppedAt in place of any
"last fired" cache — set only on the false<->true transitions, so duration
(current run, or the last completed one) is computable without a full
transition history table.

Two new read routes, GET /commanders/:sessionId/turns and .../fires, expose
the raw series behind the cost summary and watcherLastFiredAt (now computed
via MAX(fired_at), not read off a column — the field survives in the JSON
response under the same name).

Bug found and fixed while wiring this up, recorded in docs/command-hq.md:
recordCommandHqWatcherFired's signature changed from (userId, sessionId) to
(sessionRowId, taskId) and one call site kept the old argument order —
tsgo stayed clean because number/string still matched structurally. Caught
by re-reading every call site by hand, not by any tool.

Needs bun db:push before any route answers with real data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:03:52 +00:00
pastilhasandClaude Opus 5 624d67ed52 watcher loop: the agent re-arms itself, same turn — final mechanism, confirmed against real usage
second correction to the re-arm design tonight, and this one came from the
owner, not from me. my plan (platform sends a synthetic follow-up turn once
the acting turn's result lands, instructing re-arm) was technically sound —
it keeps the agent inside the sdk's own Monitor tracking, which is the thing
the FIRST wrong plan (silent respawn) broke — but it costs a real extra turn
every cycle. pushed back on with hard evidence: the mobile dev's own
watcher, on this repo, this session, had been running with THEM re-arming
themselves, same turn, for hours already — zero failures. that beats a
reliability concern I raised without having tested it.

so: handoffContract now instructs the agent to re-arm itself, as the last
action of the SAME turn that handles the notification — no synthetic turn,
no platform spawn, no extra cost. exitContract's handoff branch corrected to
match (was still saying "the harness re-arms a firing on its own", exactly
backwards now).

server side simplifies to match: createMessageHandler's task:started hook
now handles both cases with one rule — pendingArmWatcher (session-object
flag, not closure-captured, since arming happens turns after the closure
was built) claims the FIRST task:started as the watcher; every SUBSEQUENT
task:started while already armed is read as the agent's own self-re-arm and
just updates watcherTaskId to match. task:notification records the fire
when it matches the tracked id. the platform never drives anything — it
only notices what the agent already did.

pulled COMMS/command-hq/mobile-3.md, which answered the PREVIOUS (synthetic
turn) version — crossed in transit. their transcript-bloat concern is moot
now (no synthetic turn exists to bloat anything). their `watcherArmed &&
!running` detectable-failure pair stands regardless of which mechanism won
and is exactly right — kept as-is, matches their own three-state dot
(running/armed-but-not/neither). replied in platform.md.

all four proposal items now built: prompt+dialog, stopped-vs-died, the
re-arm mechanism (this commit), arming state+plumbing.

bunx tsgo clean, suite unchanged (1038 pass, same 7 pre-existing baseline
failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:33:47 +00:00
pastilhasandClaude Opus 5 f369b4ca18 watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it
item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.

command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).

armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.

the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.

so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.

docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.

bunx tsgo clean, suite unchanged at baseline (1038 pass, same 7
pre-existing failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:05:21 +00:00
pastilhasandClaude Opus 5 7229ce021d watcher loop: the prompt/dialog half, and stopped no longer reads as failed
owner's call, confirmed 2026-08-18: implement watcher-loop-proposal.md
exactly as written, before the first real tests — this governs agent
behavior generally, not just command hq. items 1-2 of the proposal's own
suggested order, done; items 3-4 (harness-owned re-arm, arming state on the
commander row) are the harder half, next.

FireBehavior replaces the stopOnFire boolean: 'stop' | 'handoff' |
'keep-going'. handoff carries the proposal's one-sentence contract verbatim
("the harness re-arms you — do not re-arm yourself, and do not build your
own polling loop on top of this one") and rewrites the closing IF IT EXITS
block so it stops contradicting rule 7 (the old universal "do not restart
it, i will start it again" directly lied about what handoff jobs actually
do).

isCommander gates the option to where the promise can be kept. the proposal
frames this entirely in terms of commanders — arming state's only home is
the commander row — so outside one there's nowhere to persist "keep
re-arming this," and offering the option there would make a promise nothing
fulfills. not wired to true anywhere yet: command hq's own composer, which
would set it, doesn't exist. every existing /chat caller is unaffected —
two options, stop default, unchanged from before tonight.

stopped-vs-died (§5): a job officer had just SIGTERM'd came back
status:'failed' (exit code 144) from the harness's own classification,
which this codebase does not control. fixed client-side — the tray already
knows the instant stop is clicked, so it now also marks that, and
useChat's task:notification handler overrides the displayed status
regardless of what the harness reported. known gap, stated in both the code
and the doc: in-memory, so it doesn't survive a refresh in the narrow window
between clicking stop and the notification landing.

docs/command-hq.md gains "the watcher loop" section: what's built, what
isn't, the proposal's own suggested order followed as given.

bunx tsgo clean. suite: 1038 pass (+9 new tests), same 7 pre-existing
baseline failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:52:06 +00:00
pastilhasandClaude Opus 5 f810bdc48e command hq: displayText, caught by the mobile side before either of us ran this
mobile-2.md found it: a commander created to become a background job sends
buildBackgroundJobPrompt's full mechanics as `prompt` — a page of preamble,
the owner's actual sentence only at the end — and command_hq_sessions has no
name column, so the title comes straight from `(displayText ?? prompt).slice(0,100)`.
without it every such commander titles itself with the mechanics text, and
the grid becomes a column of identical unreadable cards. same precedence the
websocket path already uses on both clients (sendWithHiddenPrefix on web,
useSessionView.ts on mobile) — now the same on this endpoint too.

fixed before either side had run this against a real server, so there's
nothing to clean up on a live grid.

also confirmed rather than assumed, per mobile's other note: cwd cannot be
talked into a different one on this path. resolveChatCwd's command-hq branch
ignores msg.cwd unconditionally and this endpoint never sets it.

replied in COMMS/command-hq/platform.md. also read watcher-loop-proposal.md
(exit/act/drain/re-arm as harness infrastructure, "Andre's call" to build it
before first tests) — not starting it off a COMMS note alone, taking it to
the owner directly since it's a real scope addition beyond what tonight's
command hq build was scoped to.

bunx tsgo clean, suite at baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:33:09 +00:00
pastilhasandClaude Opus 5 7ad64445ee command hq: POST /commanders/new, for a client that cannot open the websocket
mobile's need, decided with the owner in COMMS/command-hq/mobile.md, now
built: { prompt (required), categoryId? } -> { sessionId, cwd }, sessionId
being claude's REAL transcript id, not a placeholder.

the owner confirmed prompt is required and described why: a commander
usually starts as an ordinary back-and-forth before a background job is ever
armed via the existing dialog, sometimes it's just "you know what to do" —
either way there's always a real first message, same as any conversation.
no empty-commander case to design around.

getting a real transcript id costs actually starting the turn, since claude
assigns one only once system.init has been reported. extracted the
turn-starting half of handleClaudeCodeChat into startClaudeTurn(ws: ... |
null, ..., onOutcome?) so both the websocket path and this endpoint drive
one spawn function instead of two that drift — handleClaudeCodeChat is now
a thin wrapper with an unchanged signature. onOutcome mirrors every
sendToClient(ws, {type:'error',...}) refusal point (member not signed in,
claude not installed, spawn failure) plus the session:claude success case,
so an http caller gets the real reason instead of a generic 30s timeout.

the category-write race: fileAsCommander (same hook the web path uses,
since context: 'command-hq' is set either way) always files with no
category. the endpoint's own createCommandHqSession is upsert-safe
regardless of which insert wins, and an unconditional setCommandHqSessionCategory
UPDATE runs after — always last, always wins, so the requested category is
what's left standing no matter the race.

said plainly in COMMS/command-hq/platform.md: this refactor of live spawn
machinery did not get the second-pair-of-eyes review i said i wanted before
touching it. bunx tsgo clean, suite at the same 7 pre-existing baseline
failures. not exercised against a running server on either side yet.

docs/command-hq.md updated throughout — API table, "how a commander is
created", "still open" now empty of blockers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:29:57 +00:00
pastilhasandClaude Opus 5 e9b1ce5e94 command hq: the web creation path, and a member could not have used it
`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.

found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.

the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.

pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).

docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.

bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:58:23 +00:00
pastilhasandClaude Opus 5 eb94af87f4 command hq: the api, mounted and documented
full REST surface for categories, tags and commanders. core permission
(personal: ['/'], no grant to hold — every query is scoped to the caller's
own user_id at the sql layer regardless). registered in the permission
registry and PROTECTED_MOUNTS together, so totality holds by construction.

GET /commanders is the grid endpoint: everything /chat/sessions already
returns per session, plus categoryId/tagIds/running. `running` is derived
live off collectRunningTasks (now exported from websocket.ts) folded over
chat_session_events — not a stored column, on purpose, matching the design
doc's stance that a status field would go stale exactly when a sidecar
restart silently kills a watcher.

GET /commanders/:sessionId deliberately does NOT duplicate the transcript —
that stays GET /chat/sessions/:id, unchanged. a client calls both.

/chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds,
.catch()'d so a hiccup on this feature can't take the whole chat list down)
— the other half of the one predicate this whole design turns on.

there is deliberately no POST /commanders/new: creation is an ordinary chat
turn in the commanders directory, which is the next commit.

docs/command-hq.md gains an API reference table for the mobile dev, and a
status line at the top saying plainly what is real and what is not yet:
needs `bun db:push` before any route returns real data, frontend not
started.

bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all
registry/totality drift and the pty socket test — none touch this router).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:10:50 +00:00
pastilhasandClaude Opus 5 56fe3b22df command hq: data model and the shared commanders directory
first commit of command-hq/command-hq.md's design. two tables plus a
many-to-many for tags, no server surface yet — this is data layer only.

command_hq_sessions has no name column: /chat never persists a title to
postgres, renameClaudeSession appends a summary entry straight into the
transcript's own jsonl, and that stays the ONLY place a commander's name can
live. no status column either — liveness is derived from chat_session_events
the same way the background-job tray already derives it, because a stored
status would go stale the moment the harness sidecar restarts without a
final task:notification and nothing would ever correct it.

category is a plain FK (one per commander, confirmed in spec); tags are the
many-to-many case, schema only, no ui planned yet.

also: getCommandersDir(email) -> DATA_PATH/<email>/commanders, one shared
directory for every commander a user has. the first design was per-commander
directories and was dropped same-night: creating a fresh directory timed
against the harness's own spawn is a race, and a shared pre-existing
directory removes it instead of handling it. deliberately DATA_PATH-rooted
even for the owner, unlike ordinary /chat which runs in the owner's real
home — that's what keeps commanders out of both the owner's own project
transcripts and the /chat list.

bunx tsgo clean. no db:push yet — next commit is the router, and the table
needs to exist before anything can call it; will note the push requirement
clearly when that lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:03:13 +00:00
pastilhasandClaude Opus 5 0f93262a8c usage panel: verified for one account, not for two
the owner confirmed it live the same day. what that leaves untested is the
half the cache exists for — a second login, the tabs, a stored row surviving
a switch — so the docs now say which half is proven instead of claiming
neither is.

also records the bug the first deploy found, since the doc had asserted the
degradation that turned out to be broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:12:47 +00:00
pastilhasandClaude Opus 5 19f558077e document the usage panel and what testing it would mean
it has never run: db:push has not been applied and neither process has been
restarted. docs/ai-usage-panel.md records where every number comes from and
why officer cannot derive any of them itself, the two things the live
payload settled (every model-scoped bucket is null while limits[] carries a
real fable window; the window is named by kind, never by the model name),
the measured costs, and the staleness rules — which are the part most likely
to be wrong, because they assert things about data they did not measure.

sixteen ordered tests in five groups. group C is the actual feature. test 10
is the one that would break silently: a refresh must not overwrite an
account it could not measure.

also records what the scan measurement found, since it decides the deferred
work — 74.7% of message ids appear in more than one transcript file, so
per-file aggregates cannot be summed and an incremental scan is not the
one-liner it looks like.

TODO.md gains an entry under "waiting on the owner to run it", beside plan
mode, which is also still unexercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:55:30 +00:00
pastilhasandClaude Opus 5 2f31d464be plugins: declare unauthenticated paths, validated; mounting designed and measured
half of the primitive kiosk needs. the manifest field and its validator are in;
the mounting is documented and not wired, so nothing serves anything publicly
today.

paths are relative to the plugin's own mount and refused rather than corrected:
no leading-slash-missing, no .., no //, no query or fragment, wildcard only as a
trailing /*, and never the bare / or /*. the plugin is trusted with what those
paths DO, never with which paths.

the mounting was measured rather than reasoned about, and two obvious designs
are wrong:

mounting the public router at the plugin prefix gives correct fall-through but
lets the router serve any route it defines - the manifest becomes documentation
rather than enforcement.

adding a use('*') guard blocks that leak and BREAKS the plugin's authenticated
api: a use('*') middleware always matches, so the parent never continues.
measured - an authenticated route 404'd with a valid token.

what works is registering only the declared patterns, delegating into a
pre-mounted app. undeclared paths never register a handler, so they fall through
naturally. enforcement by registration rather than by checking, which is why a
plugin cannot widen it.

docs/plugin-unauthenticated-paths.md has the probe results and the six
remaining steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:19:48 +00:00
pastilhas f6658bd092 docs: kiosk replaces the marketplace, and the shape it takes 2026-08-18 03:01:09 +00:00
pastilhasandClaude Opus 5 b63badaf67 remove the email residue the extraction left in the platform
a sweep for remnants after 2026-08-18. nothing was broken - no dangling
import, symbol, route or permission key anywhere in src/ - but the platform
was still speaking the plugin's vocabulary in a dozen places.

the one that mattered is live code rather than prose: ChatPanelWrapper
hardcodes workspace.id === 'email' to send context: 'email', which is the
frontend half of the pair whose backend half (resolveEmailCwd) carries a
fifteen-line justification. the two ends now both say so, including that
adding a SECOND id there is the signal the platform needs a real way for a
plugin to declare a chat context.

the rest: two dead imports left by removing the settings section, an empty
'Email' type-section header, a protocol.ts section header with no members
naming the plugin's SSE design, and a queue comment pointing at
sidecar/email/sync-runner.ts - a path in another repository now.

CLAUDE.md, AGENTS.md and working-on-officer.md described email as a platform
feature, an app permission and a pm2 process. those are the files a fresh
agent reads first, so they were the highest-risk staleness in the tree.

TODO.md's whole Email section moved to the plugin's own repository. it held
OPEN items pointing at sidecar/email/store.ts, which is exactly how a leftover
list sends someone to edit files that do not exist.

the delete-account dialog promised it would remove email accounts and
playlists. both tables belong to plugins now, so that cascade only happens
where the plugin is installed - it now says 'anything an installed plugin
keeps for them' instead of naming two it cannot guarantee.

imapflow and mailparser STAY in package.json. the plugin has no package.json
of its own and resolves from the platform's node_modules, so removing them
would break the installed plugin's sidecar. the fix is giving plugins their
own dependency manifest, not deleting these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 01:01:23 +00:00
pastilhas 4655d374ec email extraction: plugin side built, platform untouched 2026-08-17 23:56:39 +00:00
pastilhas c5ed6cc32c plan of record for extracting email into a plugin 2026-08-17 23:52:09 +00:00
pastilhasandClaude Opus 5 43fb2952fa a test plan for plan mode, for a day that is not today
the owner has no time to test any of it, so the checklist has to work for
someone with none of today's context: eleven ordered tests, what each should
show, and what its failure looks like.

test 4 is called out as the one most likely to be wrong - approving
ExitPlanMode drops the harness into default rather than back into
bypassPermissions, which is the finding that changed the design and therefore
the thing the implementation is most likely to have got wrong.

also separates what was verified from what was not: the probe runs against a
real claude, the turn-stream unit tests, and then everything end to end, which
is nothing.

the inline 'what to watch' list in chat-plan-mode.md is replaced by a pointer
so there is one source of truth, and TODO.md now says live-but-unexercised
rather than built-not-run - the restarts are done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:43:27 +00:00
pastilhasandClaude Opus 5 004f702747 no rotation needed: nobody else has ever used this install
the owner confirmed the additional accounts are test rows - none has ever
signed in, no member has opened a shell or a chat. so the proxy secret was
readable by accounts that do not exist in practice, and there is nothing to
rotate.

the mode fix stands: it is about the install that gets used, not this one.
dropping the TODO and recording the reasoning instead, because a rotation item
left sitting there reads as outstanding work forever, and the next person to
find a world-readable secret should ask who uses the box before reaching for
the alarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:24:05 +00:00
pastilhasandClaude Opus 5 56ddb6bebf close the anthropic proxy secret, and re-close it on every boot
data/sidecar/claude-state.json holds proxySecret, and that string IS the
owner's Claude Max subscription: officer-anthropic-proxy accepts it as
x-api-key, swaps it for the owner's OAuth bearer and forwards to
api.anthropic.com. it listens on 127.0.0.1, which every local account can
reach.

it was 664 in a 775 directory. nothing had ever set a mode - mkdirSync and
Bun.write at ubuntu's default umask of 002. confirmed by reading the live
secret as 'nobody', an account with no relationship to the platform, and by
confirming the proxy 401s without the key so the secret is the only control.

this does NOT mean members shared the subscription. spawnClaudeAsMember runs
their claude through setpriv --reset-env with a six-name allowlist, so
ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY cannot cross into it and their turns
go straight to anthropic on their own OAuth. that design is intact - it could
just be walked around by anyone with a shell.

fixed twice on purpose. state.ts sets 0600/0700 on every write, which is the
authoritative fix, and on every write rather than at creation because
writeFileSync leaves an existing file's mode alone. secureRuntimeSecrets()
sweeps at boot because officer-anthropic-proxy is a separate pm2 process that
can recreate the file hours after officer started, and because every existing
install has the old mode until something repairs it.

it repairs rather than refusing to boot, unlike assertSecretsClosed beside it.
the line is who wrote the file: .env is the installer's and we must not guess
at it; this is our own output at the machine's umask, so refusing to start
would punish the user for our bug.

the per-account copies are swept too. no live secret in them today, but they
map session keys to transcript uuids for named accounts.

tests verified to bite: removing the chmod while still reporting success fails
four of them.

a chmod does not undo exposure - the secret still needs rotating on the
production host. TODO.md has the sequence, including that officer-claude-code
caches it and must be restarted alongside the proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:27:09 +00:00
pastilhas 4125949468 record plan mode as built, and what to watch on the first run 2026-08-17 20:50:49 +00:00
pastilhas 9be484a105 verify plan mode is reachable on a bypassPermissions session 2026-08-17 19:10:24 +00:00
pastilhas a4907fdfdb plan of record for plan mode, and mark the sessions list built
opusplan is in the picker and does nothing on this install: it is a per-MODE model switch (Opus while in
plan mode, Sonnet at rest — the CLI's own strings call them the upgrade and resting models), and
claude-manager hardcodes permissionMode: 'bypassPermissions', so plan mode is never entered. Not
orchestration, which is what the name suggests and what we both assumed.

Making the mode reachable is the fix. setPermissionMode is a control request on the live query, same channel
as the setModel that landed in 4c24df93, so the mechanism is proven. The hard part is ExitPlanMode: it is a
permission decision, and bypassPermissions auto-approves it, so a naive toggle produces a planner that
instantly approves its own plan. canUseTool is the hook, and whether it can be supplied after session
creation is the one thing to verify before designing any UI.

Records the landmines too — bypassPermissions is deliberate per CLAUDE.md and must be restored on exit, and
a canUseTool promise that never resolves wedges the turn.
2026-08-17 18:44:03 +00:00
pastilhas fc67973876 record a resumed session's model when it switches, not when the turn ends
`createMessageHandler` is built per turn, so `transcriptId` started null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was nothing to key the row on. A resumed conversation therefore recorded its model only on the closing
`result`, and a turn that died took the switch with it.

Observed rather than reasoned: the sidecar log said `claude-code/fable -> claude-code/opus[1m]` while the
stored row still said `claude-code/opus`.

Seeded from the resume id, which the Claude path already has in scope for the spawn. A brand-new
conversation still waits for `session:claude` — it has no id yet by definition — but resumed is the case
where switching actually happens.

Also lands docs/chat-sessions-list.md: the plan of record for the sessions-list revamp, agreed with the owner
today. Six numbered targets, four decisions with their reasoning, and the landmines — chiefly that merging
the two left-column panels strands `chat-live` in every persisted layout, which is the same stale-appType
shape that made /offscale render three server pickers this morning (ed3806f9). Written down because the shape
came out of a conversation, not out of the code.
2026-08-17 17:17:52 +00:00
pastilhasandClaude Opus 5 27aa72d923 finish the offscale sweep: two real remnants and eleven stale names
Asked for a thorough check that nothing of offscale is left in the platform. The
extraction was sound — no router, no permission entry, no app-store entry, no
screen, no panel app, no env var anything reads. Two things were genuinely wrong,
and both were in setup rather than in the server.

── The secret store minted a key for a plugin ──

`officer-setup` bootstrapped two purposes: `jwt` and `headscale`. The second was a
key for a PLUGIN, under a name the rename had already replaced, on a machine where
the plugin is not installed and nothing would ever ask for it. offscale encrypts
under `offscale`, and `getKey` creates on demand — which is precisely what lets
core know nothing about plugins. Now core bootstraps `jwt` alone.

── `.env.example` documented variables for a deleted router ──

HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were described as driving
`/api/vpn`. That router was deleted on 2026-08-14 and nothing has read any of them
since. Left as a note saying where the tailnet's credentials live now, because
somebody with an older `.env` will otherwise wonder why setting them does nothing.

── Names that outlived the thing ──

Eleven comments referred to the plugin's route, panels, console or server registry
as "headscale": three sidecar config files describing their own shape by pointing
at it, two screens citing `/headscale` as the example of a Workspace/Panel route,
the terminal's note about the Console, and the photos types. Also `catalogue.ts`,
which still claimed headscale "is CORE now", and `working-on-officer.md`, which
told the reader a core install runs six processes including `officer-headscale`.

Upstream references are deliberately untouched: "Headscale's API", "the Headscale
admin credential", "is this a Headscale server?" all name the thing the plugin
drives, and renaming those would make the sentences false.

`docs/secret-store.md` argued headscale must be core because the tailnet is the
perimeter. Marked superseded rather than deleted, with the distinction that
settles it: the perimeter is the tailnet EXISTING, which machine-setup still
arranges on a core install — not the platform shipping the screen that
administers it.

Verified by counting: live code, setup provisioning, env references, permission
entries, app-store entries, hono mounts, dock defaults, screens and panel apps all
zero. What is left is generated files (Plugins.gen, plugin-schemas.gen), the
bundled catalogue entry, test fixtures using "offscale" as an example app name,
and history.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:24:23 +00:00
pastilhasandClaude Opus 5 c0e61a4cba the trap keeps its evidence, and says where the procedure is
This bug will not be reproduced on demand. It will arrive as somebody's report,
hours later, from a browser whose console is long gone — so a `console.warn` is
seen by nobody, and "what did you see?" gets an answer built from memory and a
screenshot.

So the trap now persists. Up to five occurrences in
`localStorage['officer:editor-buffer-trap']`, each carrying the stack at the moment
of the edit, the first 200 characters that arrived, how many, which file, and when.
The question becomes "paste this key", and the answer is complete.

Recording a bug must never become one: private mode, a full quota and a disabled
storage API all throw at that write, and none of them is worth breaking an edit
over. The console line survives either way.

`docs/editor-buffer-corruption.md` is the procedure — what it looks like, the four
candidates already eliminated and why, the one command to ask a reporter for, how
to read each field, what an EMPTY key would mean (either the editor was focused,
making it a paste, or the insertion came in chunks under the threshold), and the
instruction to delete every piece of this once the cause is known. Linked from
TODO.md and from CLAUDE.md's reading list, so somebody handed a report finds it
without grepping for it.

Stated there rather than left implicit: a fix invented without knowing the cause
would hide the symptom on the one screen where it is visible while the mechanism
carried on doing whatever else it does. The corruption is never saved and reopening
the tab clears it, so waiting is cheap and a wrong fix is not.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:18:56 +00:00
pastilhasandClaude Opus 5 23f2ecb950 todo: the marketplace's deferred work, ranked
Split out of docs/marketplace.md so the design document stays about how it works.
Ranked by what blocks something rather than by size: submissions first (until a
stranger's manifest can be read without executing it, the catalogue is ours only
and everything below is cosmetic), then plugin upgrades not migrating, then
staleness, then scale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:13:00 +00:00
pastilhasandClaude Opus 5 53742a256f marketplace.officer.dev is live, and the plugin repos have READMEs
The site is up on HTTPS with a Let's Encrypt certificate, proxied to :9100, and
serving the three plugins out of `officer_marketplace`. An Officer install with no
configuration now reads its catalogue from it.

── What is running ──

PM2 entry added to THIS machine's ecosystem only. Deliberately not in
officer-setup's CORE_PROCESSES: the marketplace is one website officer.dev hosts,
not something every install should run — a copy on every machine would serve a
stale catalogue on a port nobody asked to open.

nginx (NPM host id 7) → 172.20.0.1:9100, with a ufw rule for the docker bridge.
That rule is not optional and its absence does not look like a firewall problem:
without it nginx HANGS until proxy_connect_timeout rather than being refused,
which is how the apptest hosts wasted an hour a few days ago.

── READMEs ──

`music`, `offscale` and `example` now have proper READMEs, written against their
own code and pushed to their own repos. They render on the plugin's page in the
store and are what a self-hoster reads before installing. The example's is a
tutorial as much as a shop window — it is the document a plugin author reads
first, and every convention in it was verified in `discover.ts` rather than
restated from memory.

`music`'s remote was HTTPS with no credentials on this box, so it could not be
pushed; switched to SSH like the other two. The catalogue still advertises HTTPS —
`marketplace-sync` converts — which is the property that matters, since an
`ssh://` clone URL only works on the machine that published it.

── Writing them was the cheapest audit available ──

Reading each plugin against its own documentation turned up ~20 real defects, all
recorded in TODO.md and none fixed here. The pattern worth naming: offscale is
still branded "headscale" in user-visible panel names and in a persisted terminal
session id, and its PLUGIN.md states outright that the ACL assistant is
deliberately unwired — while `sidecar/assist.ts`, `claude-proxy.ts` and
`PolicyAssistant.tsx` all ship. Documentation that confidently describes the
opposite of the code is worse than none.

Also fixed the docs on the frozen lockfile: both CLAUDE.md and bunfig.toml named
`bun install --no-frozen-lockfile` as the escape hatch, and bun 1.3.14 has no such
flag — it is ignored and the install fails anyway.

tsgo clean, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:15:34 +00:00
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00
pastilhasandClaude Opus 5 4a9f23c759 the design doc moves into the plugin it produced
docs/offscale-plugin.md becomes plugins/offscale/PLUGIN.md. most of it is about
the plugin system generally rather than about offscale, which is exactly why it
belongs with the worked example — the reasoning is most useful beside the code
it produced, and the platform's docs should not carry the history of something
it no longer knows exists.

every reference updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:07:48 +00:00
pastilhasandClaude Opus 5 8b6cb34ae0 plugins contribute dock tiles, and the doc says what is actually built
offscale had no dock tile: that field comes from the app store's catalogue, so a
plugin installed through the plugin system was reachable only by typing its url
or following the link on /plugins.

built at runtime rather than baked into the generated bundle, deliberately —
WHO sees a tile is a permission question, and a grant takes effect on the next
request rather than the next build. presentation comes from the manifest and the
route from mountPrefix, so there is one source for both, and  is the
plugin's first permission so the endpoint can filter a tile out for an account
that cannot reach the screen.

two sources for tiles today, because the app store still has its own catalogue.
one when it is rebuilt on this.

the doc now records the system as complete rather than half-stubbed, including
the three bugs the extraction found — the sidecar-before-mount ordering, the
missing tailwind, and the build that could delete its own shell — and what is
genuinely still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:31:54 +00:00
pastilhasandClaude Opus 5 543e88a9a6 every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:47:48 +00:00
pastilhasandClaude Opus 5 ed195e0904 record what got built tonight
the plugin system works end to end for a plugin with an api/router.ts, at
runtime, with no restart. what is wired, what is not (schema push, the sidecar's
pm2 entry, websocket providers, totality across plugin routes), and what was
deliberately left: offscale is not extracted, because moving it deletes working
code across ~50 files and that wants someone watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:28:18 +00:00
pastilhasandClaude Opus 5 0ae0a5dc58 music is where the richer permission model gets designed
offscale is deliberately the simple case — one shared resource, read or write.
music is the next extraction and the right place to build the in-plugin
visibility system, because it has real per-user data (favourites, playlists,
now-playing) on top of a real shared one (a single global library index). so
'whose is this row' has a non-uniform answer there, where offscale's is just
'the owner's'.

not designed yet and deliberately not designed here. recorded so the intent
survives the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:06:55 +00:00
pastilhasandClaude Opus 5 acd51c969c the platform grants read or write; richer rules belong to the plugin
the platform's contract is what it already has: a role holds read or write on a
capability, stored in role_capabilities and enforced by the gate. anything
beyond — who sees whose rows, per-user isolation, record ownership, visibility
of any kind — is the plugin author's job, inside the plugin. the platform should
not grow machinery for it. a plugin knows what its data means; the platform only
knows whether this account got through the door.

offscale v1 uses that exactly. one shared resource: read sees what the owner
sees, write can change it including deleting a server the owner registered. that
is dangerous on purpose — the stored credential is a headscale admin key with no
read-only equivalent, so write is close to full control of the tailnet, and that
is the owner's call. expected use is read for most roles.

two consequences, both inside the plugin. the queries stop scoping by the caller
and resolve to the owner's id, leaving the per-user shape in the table unused as
the seam if isolation is ever wanted. and two POSTs are really reads —
/ssh-test probes and /policy/assist explicitly never saves — so they need
readOnlyWrites, or a read-level account finds a broken feature where a withheld
permission should be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:04:18 +00:00
pastilhasandClaude Opus 5 56bb383c6d a plugin installs with no questions unless it says otherwise
offscale needs none of the install fields the catalogue carries — no modes, no
existingFields, no configFields, no composeTemplate, no members. nothing to
provision, nothing to point at. install is put the code there, push the schema,
start the sidecar, swap the routes, and it is available.

configuration happens afterwards inside the app, which is already how headscale
works: a server is registered at runtime and lands in offscale_servers.

so no-questions is the default rather than offscale's special case, and the
prompting machinery gets designed against the first extracted plugin that
actually needs docker or a remote instance. part of why this was the right
pilot — it exercises mounting, schema and sidecar without install being a
variable at the same time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:15 +00:00
pastilhasandClaude Opus 5 9f903479ce websocket routes reload too, so nothing needs a restart
the last gap. six ws providers live in bun's route table rather than hono's, so
the app swap does not reach them — but server.reload({routes}) does, and in both
directions: refused before, connected after install, refused again after
uninstall, with core routes untouched throughout.

so a plugin can own a socket from the start, and no part of an install needs the
process restarted.

still untested: whether connections already open across a reload survive it.
that matters before an install is allowed to interrupt somebody's terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:46:07 +00:00
pastilhasandClaude Opus 5 6ab838c77f mounting at runtime after all: rebuild the app and swap it
this went round twice — runtime dynamic, then generated-plus-restart on the
belief that hono could not mount after serving, then back once that was actually
tested. the doc keeps the route rather than just the destination.

tested: SmartRouter (hono's default) and RegExpRouter both throw 'Can not add a
route since the matcher is already built'. TrieRouter and PatternRouter accept
it. so runtime adding is possible but costs the fast matcher, and hono has no
remove-route api at all, which uninstall needs.

what solves both is not adding routes but rebuilding: construct a fresh app from
the current plugin set and reassign the variable. the fetch closure reads it per
request, so the reassignment is the swap — atomic, no dropped connections, no
server.reload, and the default SmartRouter is kept. verified 404 before install,
200 after, 404 again after uninstall, with core routes unaffected throughout.

the mechanical cost is one line: server.tsx:322 is '/api/*': honoServer.fetch, a
bound method evaluated once at serve(), and has to become a closure or the swap
does nothing.

websockets stay open: six providers live in bun's route table rather than
hono's, so a plugin owning a socket needs server.reload({routes}), untested.
offscale has none.

and totality stops being a boot check — buildApp() is now the single place
routes are mounted, so it is where the assertion belongs, refusing the swap
rather than refusing the boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:45:22 +00:00
pastilhasandClaude Opus 5 13437e0e48 mounting: generated then restart, reversing the call for runtime dynamic
this reverses the earlier decision for C (mount and unmount at runtime) and says
so rather than quietly overwriting it.

the requirement behind C was that the platform must not need to know a plugin in
advance. that is met either way: what it reads is a generated file listing the
installed routers, analogous to Plugins.tsx on the frontend — nothing hardcoded,
nothing read from a table at boot, the imports made concrete at install. C would
have bought only the absence of a restart.

and a restart is close to free here, because sidecars are pm2 peers rather than
children — a property that was fought for, since officer used to spawn the agent
and pm2's tree-kill took the owner's chat down on every restart. what a restart
costs is websockets, which reconnect, and in-memory session records, which
claude:list already recovers.

the happy consequence is that assertCapabilityTotality stays a boot check
instead of becoming a per-mount transaction. it does need to be fed the route
table rather than Object.keys(handlers) first — generated mounts widen that gap
rather than closing it, so that is a prerequisite and not a tidy-up beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:35:55 +00:00
pastilhasandClaude Opus 5 7befaf032a the manifest holds only what the tree cannot say
lean it to identity facts and human choices: publisher, version, platform range,
the four presentation fields, and permissions. everything structural becomes
convention, where presence is the declaration — sidecar/, api/router.ts,
db/schema.ts, web/Router.tsx, web/panels.ts. appName comes from the directory
name, so the id cannot disagree with where the code sits.

the dock tile and page title needed no fields at all: the tile is label + icon +
color + mountPrefix, and the title is label. writing them again was duplication
that could only drift.

runtime is the file extension. index.mjs is node, index.ts is bun — implicit,
but already the rule here, since officer-pty runs under node for node-pty's abi
and everything else is bun. better than a field that can contradict the file.

dependsOn is gone; nothing read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:32:07 +00:00
pastilhasandClaude Opus 5 4dc7cd90c2 a plugin declares permissions, not capabilities, and has no kind
'capabilities' already means three things in this codebase — the permission
registry, the officer-items store, and the sidecar's routing keys. a fourth
would be one too many, and the field is really just permissions. the name is
free: the old permissions table went in 044aacf4.

and the kind enum is gone with it. the first draft handed a plugin the
platform's own five-value CapabilityKind and then forbade three of them. those
five exist because the platform has five sorts of surface; a plugin has two —
grantable to members, or owner-only. a boolean says it, and says it without
needing a prohibition: a plugin cannot claim core if core is not a word it can
say.

offscale is ownerOnly: true, which is what kind: 'admin' meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:25:54 +00:00
pastilhasandClaude Opus 5 b18601530f a manifest for offscale, and the rule it immediately broke
written against the real plugin rather than invented as a field list, on the
theory that an abstract one includes what nothing needs and misses what is
awkward. that paid off on the first field that mattered.

the rule here said a plugin may declare `app` and nothing else. offscale's
capability is `admin` — owner only — and should stay that way, so the rule was
wrong. the distinction is direction, not privilege: `core` means every account
and not deniable, so claiming it grants yourself to everyone; `admin` means
owner only, which is a plugin restricting itself. corrected table in the doc.
core, execution and confined stay the platform's to assign.

`publisher` is the only input to the mount prefix, through one function, so
first-party and third-party cannot drift into two code paths.

sidecar.runtime is a field because officer-pty needs node for node-pty's abi
while everything else is bun — one plugin already needs it, so not speculative.

dependsOn is informational and unenforced. code dependencies need no declaration
now that a plugin builds inside the workspace, and service dependencies already
degrade; this exists so the store can say the console section wants the terminal
plugin, rather than the section silently doing nothing.

health is marked deferred rather than open, with the reasoning, so it does not
get re-raised. migrations likewise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:05:15 +00:00
pastilhasandClaude Opus 5 f6b2905cc7 how the frontend ships, and what plugins may depend on
everything moves to the plugin, frontend included, so federation stopped being
a later problem and had to be answered. it is answered by not needing it: bun
builds the spa into build/ at start and rebuilds it on install, serving from
that directory instead of compiling through the html import. Bun.build is a
runtime call, so an install needs no restart — just a refresh. same origin
throughout, which is why there is no cors work and no rewrite of useClient.

App.tsx keeps core routes and gains one map over `plugins`, each mounted at a
wildcard delegating to the plugin's own router. that list comes from a generated
Plugins.tsx, because a bundler cannot follow import(runtimeString) — the
specifier has to be concrete before the build. the six places the shell
currently hardcodes headscale collapse into that one file, dock included; the
runtime dockItemsFromPlugins path follows rather than competing with it.
presentation moves to build time, permission stays runtime.

dependencies turned out to be two different problems wearing one word. a service
dependency (assist → anthropic-proxy) is a wire call and already degrades. a
code dependency (ConsoleView → TerminalView) is in the bundle and cannot. rule:
may depend, must degrade. service calls go through the api carrying the user's
token, with the user's own permissions, which also deletes the state-file read
claude-proxy uses today to lift the proxy's secret.

no per-plugin permission list: a plugin is part of the app and bounded by the
account calling it. that makes marketplace review a security boundary rather
than a naming one, which is worth knowing rather than discovering.

and the developer environment is a platform checkout — clone it, run dev, build
the plugin inside. the 13 workspace packages resolve by name because bun links
them, so `import { useClient } from 'hooks/useClient'` just works with no
registry and no versioning. dev-time and build-time become the same mechanism.

also writes down the headscale inventory now that it has been read end to end,
including that assist.ts travels unwired as a marker and must not be tidied away
as dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:58:22 +00:00
pastilhasandClaude Opus 5 7f26f0b4b8 offscale is headscale plus the companion, not a rename
the name looks like branding on someone else's project, which is exactly how it
gets 'corrected' back later. it is not: offscale is the stock headscale server
plus the companion that ships beside it, and the invite flow is the first thing
that only exists there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:44:28 +00:00
pastilhasandClaude Opus 5 01a20fff4e the invite flow replaced device enrolment; it is not a gap
closes the one open item left by deleting /api/vpn. removing the vpn capability
leaves no member-grantable headscale surface and that is correct: the owner
mints an invite from the headscale app, the companion turns it into the redirect
the phone claims, and the device joins. no per-member permission on officer is
involved at any step.

recorded as decided rather than open so nobody reintroduces a member-facing
enrolment route believing something was lost. nothing was — /api/vpn/enroll was
the design the invite flow replaced, and it never had a UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:43:42 +00:00
pastilhasandClaude Opus 5 88a44ec4a7 delete /api/vpn
it had no caller. verified three ways before removing: nothing in the mobile
monorepo reaches it (enrollVpn's only call site is behind `if (embedded)`, and
the one app rendering VpnScreen never passes embedded), nothing in the officer
web app references it, and the live database holds no vpn grants. the companion
was checked separately by its own author — zero references there either.

and it will not come back. offscale is permanently standalone: the thing that
gets you to the platform cannot itself need the platform, or a broken tailnet
locks you out of both.

gone: api/vpn/router.ts, its mount, and the `vpn` capability. the registry keeps
a comment where the capability was, because its removal has a cost worth
recording — headscale is admin-only, so no member-grantable headscale surface
remains, and reintroducing one is a deliberate act rather than an oversight.

kept: the sidecar's enroll.ts. its bare POST /_officer/enroll handler is now
unreachable, but the file is also the dispatcher for /enroll/invites, which is
live and fundamental. the header comment now says so, so nobody deletes it
looking for dead code.

also records the third component in the doc. two of the three have an "enroll"
surface and only one is ours: /api/v1/enroll/* belongs to the companion, is
where the phone actually goes, and must not be collapsed into /api/offscale/*.

capabilities tests: 17 pass / 8 fail both before and after, stash-verified — the
8 are pre-existing, in totality and path-to-capability, which is precisely the
machinery dynamic mounting will rework.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:40:44 +00:00
pastilhasandClaude Opus 5 bbc60b34ac headscale leaves the baseline, and offscale gets a design doc
first step of extracting headscale into a plugin. CORE_PROCESSES is five now,
and the catalogue.test CORE[] mirror follows it — not optional, since that list
asserts "the catalogue must not offer a core process" and would have blocked
adding offscale to the catalogue later.

the local generated ecosystem file lost its entry too, and officer-headscale was
stopped and deleted from pm2 by hand. the platform still mounts /api/headscale
and still declares the headscale and vpn capabilities, so the feature is
present-but-unavailable rather than gone.

docs/offscale-plugin.md is a live document for the rest of it. what it records
that nothing else does: core is now `officer` alone and everything else is a
plugin; routes are /api/<app-name> for ours and /api/p/<creator>/<app-name> for
third parties, derived by one function so the two can never become two systems;
tables stay in public with an app-name prefix; mounting becomes genuinely
dynamic, which retires the "every route stays mounted" premise and relocates
assertCapabilityTotality from a boot check to a per-mount transaction.

it also records a rejected experiment with evidence — a postgres schema per
plugin works completely, including cross-schema FK, idempotent push and
DROP SCHEMA CASCADE as uninstall — and the reason not to: drizzle-kit 0.31.8
needs schemaFilter naming every schema, contradicting its own docs, and without
it push reports "No changes detected" and creates nothing. a plugin install that
reports success and makes no tables is the exact failure shape we have hit three
times this week.

and /api/vpn is dead: no caller in the mobile monorepo, none in the web app, no
grants in the database. offscale is permanently standalone, so it never comes
back. the invite flow is unaffected — the phone claims from the Companion, not
from officer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:37:55 +00:00
pastilhasandClaude Opus 5 cc1eab7794 docs: a triage map of the documentation
42 documents, 13,000 lines, and no way to tell from a filename which describe the
system as it is and which record an afternoon in July. This sorts them: living,
stale, historical, and two clusters that want consolidating.

Says plainly how much was verified — mostly filenames, status lines and greps for
what changed today — so it reads as a starting point rather than a verdict.

Names the two obvious consolidations without performing them. Nine opencode
documents for one migration that has landed (verified: `opencode serve` is in the
sidecar, so the plan's "nothing here is implemented" is false), and three
mobile-dav documents that are one correspondence. Both need all of them read
first, which is not a 4am job.

Marks the historical ones as not-to-be-rewritten. claude-sidecar-isolation.md
records the officer-claude to officer-agent rename that preceded tonight's rename
to officer-claude-code; editing it to match today's code would destroy the
reasoning it exists to hold.

And notes what most of them share: they were written when the estate was twenty
processes and everything was simply present. A core install is six. The fix is
usually one line — say whether the thing is core or a plugin — not a rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:26:48 +00:00
pastilhasandClaude Opus 5 8826c2847e docs: working-on-officer catches up to the code
It described four capability kinds and said terminal, chat and files can never be
granted. There are five, and those three moved to `confined` on 2026-08-11 — the
kernel enforces the boundary because the account has its own Linux user, and a
grant means nothing without one.

The layout diagram was missing dockers/ and secrets/, and implied the paths are
configured. They are derived from the working directory, which is why the pm2 cwd
pin and assertInstallLayout exist.

Adds what is switched off as of tonight: six core processes, every plugin router
commented out beside its capability claim, the ecosystem files now generated, and
.env down to three values with the keys in the secret store.

First of a documentation sweep. 42 docs; this one first because it is the
operational guide somebody actually reaches for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:26:07 +00:00
pastilhasandClaude Opus 5 236a3a5481 docs: first container test pass, and what it found
Ubuntu 24.04, Debian 12, Arch and Fedora 41. OS and package-manager detection
correct on all four; --help works unprivileged; and the install report is written
end to end in a container that had never seen this code — task 1's mechanism
confirmed off the machine it was written on.

Three real findings.

--only does not isolate a step. Running --only "Core utils" still created a user
account, because ask_username and the account creation sit in the preamble above
the step framework, so everything before the first `step` runs every time. It is
defensible and it is not what the flag appears to promise.

.setup-answers travels with a copy of the tree. Correctly gitignored and 0600,
but it lives inside the repository directory, so `cp -r` carries it — a container
that had never run setup came up already knowing the username and created that
account. Nothing secret in it; it is a surprise, which in an installer is the
expensive kind.

adduser leaks its own interactive prompt ("Try again? [y/N]") on the
account-creation path. Harmless here because the run had already stopped, but a
hang on a real unattended install.

Also records what containers cannot reach: no init means systemd, netplan, ufw
and the sshd drop-ins are only verifiable as "wrote the right file"; Docker and
Postgres are untested; macOS is unreachable entirely and everything about it is
reasoned rather than executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 04:00:31 +00:00
pastilhasandClaude Opus 5 d7d64cd6d6 docs: the install-variant tree, for tomorrow's decision
Enumerates the forty-seven prompts the two scripts actually ask and sorts them
into branches, consents and values — the distinction that decides what a
generated leaf can remove. Four real branches (OS, role, tailnet state, which
half), fourteen consents that let a leaf omit a section entirely, and a set of
values that must stay prompts because baking them in would mean publishing
somebody's hostname.

Names the two things that need deciding rather than deciding them:

Whether a leaf strips dead code or sets constants and calls the base. They are
different artifacts and the plan rests on which one is meant — the first is what
makes it auditable by being short, the second is what keeps it maintainable.

And the combinatorics: 4 OS x 3 roles x 3 tailnet states is 36 leaves before
consents, so the tree cannot be the full product. Publishing a few opinionated
leaves keeps the static-file-anyone-can-diff property; generating on demand does
not, which is the property per-leaf scripts existed for.

Also notes that --unattended and a generated leaf are the same mechanism seen
twice, and that install_config's existing behaviour — keep the user's file when
there is no tty — is the conservatism every unattended answer needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:58:32 +00:00