f369b4ca18a3fc55d885c27812e9dcfaaf4b7297
122
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
3cc9df27d7 |
a failed usage write must not discard the reading
the panel rendered "Not signed in" over an account that was signed in fine. cause: `snapshotClaudeUsage` had the read and the write in ONE try block, so on a machine that had not run `db:push` the upsert threw on a missing `ai_provider_usage`, the catch returned null, and a perfectly good reading went in the bin. the route then merged null into an empty stored list and answered with no accounts at all. which makes the claim in docs/ai-usage-panel.md — "degrades to no tabs rather than a 500" — false as written. the stored half is an ENHANCEMENT: it buys tabs for accounts that cannot be measured, and an enhancement that fails must cost only itself. so the write gets its own try and the reading is returned either way. observed on this machine rather than reasoned about: to_regclass came back null, and the regression test fails against the old shape (verified by reverting it, not by assuming). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9745f54fa1 |
cache usage per ai account, so a switched-away account still answers
half a usage reading cannot be taken for an account you are not signed into. the limits come from anthropic's oauth endpoint using the credential the cli holds for the CURRENT login, and the tokens come from that login's transcripts — switch accounts and the previous one is not slow to read, it is unreadable. so: `ai_provider_usage`, keyed (user_id, ai_provider, email_account), with a snapshot taken at the end of every turn. fire-and-forget off the `result` message, owner-gated, capped by the reader's own 60s cache so a burst of short turns is not a burst of 150MB scans. the panel gains tabs, one per account. GET /chat/usage now answers from postgres alone — instant, and it carries the accounts that cannot be measured — and ?refresh=1 re-measures the signed-in one and writes it back. the client fires both and swaps. the reason a stale row is worth showing rather than apologising for is that `resetsAt` is an ABSOLUTE instant. a row from six hours ago has an as-of percentage and an exactly correct "resets in 14h 20m", ticked client-side with no network. and where a window has rolled over since, `currentLimit` reads it as 0% — the stored figure is not merely stale there, it is wrong in the direction that matters, since the account is actually free. weekly rolls forward to its next boundary (the length is in the kind, so that is arithmetic); session does not, because the five-hour window starts on first use rather than on a grid and there is no clock to count down. fetchedAt is now the older of the two halves rather than a stamp at the end, so a cached reading does not report itself as current. needs `bun db:push`. degrades to no tabs without it rather than a 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc68a289ff |
usage panel on the claude code pill
clicking the harness pill opens what claude code's own usage display shows: session/weekly/scoped limits with reset countdowns, tokens by day for the last week, tokens by model. officer has none of these numbers and could not derive them. per-session cost is in-memory in websocket.ts and dies on restart, and the rate-limit windows belong to the ANTHROPIC ACCOUNT — spent by every `claude` on the box, not only by this chat. so both halves come from the cli's own record: the oauth usage endpoint for the windows, ~/.claude/projects for the tokens, ~/.claude.json for which account is signed in. shape and the non-obvious correctness details are lifted from omarchy's agents collector (basecamp/omarchy quattro, via pastilhas.agents which adds the account email): dedup by message id, the four token counters, the per-payload percent-vs-fraction scale, and reading the model-scoped window out of limits[] rather than the flat buckets — every seven_day_* bucket is null on this account while limits[] carries a real fable window. verified against the live payload today; limitsFromPayload is tested against it. owner-only in the handler, not the registry: `chat` is confined, so a member holding it reaches every route here, and these are someone else's windows. the pill falls back to the plain label it always was for anyone else and for opencode. 442ms cold, 1ms warm on 87MB of transcripts; cached 60s server-side, and the query only runs when the popover opens. not exercised through http — the route needs an officer restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
77f90a91f0 |
show the model that answered instead of the bare harness string
the picker rendered the literal `claude-code` for any chat that named no
tier. that is DEFAULT_MODEL — the bare harness — and it routes correctly
(no --model is passed, so the cli picks for itself) but names no catalogue
entry, so radix matched no item and the fallback from
|
||
|
|
98c37c4ce9 |
email leaves the platform for plugins/email
the sidecar, the router, the table, the screens and the account manager are
gone from src/ entirely - deleted, not commented, because a commented mount is
what assertPermissionTotality cannot see.
three reverse imports had to go first, and they are what decided the boundary.
core may not import a plugin's queries, and one of these had already proved
why: getEmailAccounts was awaited at the top level of the agent sidecar, so
when the table went away on 2026-08-13 the rejection took officer-agent into a
pm2 restart loop with every live chat session inside it (
|
||
|
|
1b811dfa28 |
make plan mode reachable in chat
opusplan has been in the picker since
|
||
|
|
773f0e1c8b |
never store a model the picker cannot render
The selected model went blank after a turn: visible only by opening the dropdown.
`officerModel` was coming back as `claude-code` — the bare harness. That is `DEFAULT_MODEL`, used when the
client sends no model and the user has no default. It ROUTES fine (isClaudeModel is a prefix test, and
split('/')[1] is undefined so the CLI picks its own), but it names no catalogue entry, so Radix's
`SelectValue` matched no item and rendered nothing.
It fed itself. A blank picker sends no model, so the next turn fell back to the bare harness again and
overwrote the real value — it destroyed a correctly backfilled `claude-code/opus` within one turn. My own
write path did that, added this afternoon.
Two changes. The server refuses to record a model with no tier: a bare harness means "no explicit choice",
and the honest response is to leave the previous answer alone rather than overwrite it with a non-answer.
And the trigger no longer trusts that its value names an item — an unmatchable value renders as its own
string instead of as an empty control, which also covers a retired model or a vanished OpenCode provider.
Repaired the one corrupted row in place by deleting it, so the transcript family fallback answers for that
session until its next real turn; it now reports claude-code/opus again.
|
||
|
|
d59bc0d152 |
one sessions panel with three views
The /chat sidebar was two panels in a vertical 25/75 split — Live above, Sessions below — plus a directory
dropdown in the list header. Three problems that compounded: the Live panel was empty almost always, so a
quarter of the sidebar was permanently blank; the two panels had entirely disjoint indicators, so a
conversation with a turn running looked ordinary in the history; and the dropdown showed one directory at a
time, which turned "where was I working on that" into a search.
Now one panel, three tabs. Tree answers "what have I got, by project". Latest answers "what was I just
doing". Live answers "what is running". Same rows, same indicators, one place.
Tree is an accordion over /chat/pwds, each header carrying the full path from ~, the session count, and a
live count — plus New Chat here, since the tree only lists directories that already have history. A
COLLAPSED NODE ISSUES NO REQUEST: sessions are read by scanning transcripts off disk, and eager mounting
would be one scan per directory for rows nobody asked to see. `useClaudeSessions(cwd, {enabled})` is the
whole mechanism. The per-directory live count comes from the poll's own `cwd`, never from
`liveSessionTitle`, which reads a whole transcript and whose own comment warns against calling it from
anywhere hotter than a ten-second poll.
Latest needs a new server mode, `GET /chat/sessions?all=1` → `listAllClaudeSessions`. It loops directories
rather than scanning flat, because /clear chains are merged per group and a flat scan would let two
unrelated chains in different directories collapse into each other. Fetched only while the tab is showing.
Rows now carry the model, from the batched `getChatSessionModels` — one query per page, which is what that
query was added for.
THE MIGRATION IS THE DANGEROUS PART AND IT IS INVERTED. Every persisted layout still names `chat-live`, an
appType that no longer exists; `normalizeLayout` would collapse it onto the fallback and WRITE THAT BACK,
which is what made /offscale render three server pickers this morning (
|
||
|
|
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 (
|
||
|
|
813bc200bf |
a session remembers which model it is using
resume a conversation and the model picker showed the first entry in the catalogue instead of the model the session was actually on. that was invisible for as long as the first entry was `opus`, and became obvious the day `fable` went above it — so this is a pre-existing bug my model-list change exposed, not one it introduced. two defects, stacked. the value did not survive a refresh: `initialModel` came from `props.model ?? locationState?.model`, and both are volatile — a panel channel and react-router location state. on a hard load neither exists, the settings default is skipped (it only runs for NEW chats), and ModelSelector's fallback effect fired `onModelChange(providerModels[0].id)`. and even when it did arrive it could not match. a transcript records the API model; the picker offers CLI aliases. `claude-opus-5` is in a different id space from `claude-code/opus[1m]`, and the mapping is one-way: opus and opus[1m] both serve claude-opus-5, opusplan serves whichever model answered. read out of a real 59MB transcript, the distinct values were claude-opus-5, <synthetic>, claude-sonnet-5, claude-haiku-4-5-20251001, claude-fable-5 — note the synthetic marker and the inconsistent date suffix. so the transcript cannot answer "what should the picker show", and a new table does: chat_session_models (session_id pk, model, updated_at) officer's own id space, so opus[1m] and opusplan round-trip. keyed on the TRANSCRIPT uuid rather than officer's session id, because that is what the session list joins on — which is why the write waits for the harness to announce the id rather than firing at turn start. records the EFFECTIVE model, so a refused switch stores what answered. not derived from chat_session_events, which does now hold model:switched: right data, wrong index. that table holds every assistant message and is unbounded, and "latest model for these 200 sessions" would scan far more than the question deserves. not a chat_sessions table either — a session IS its transcript, and a row holding title and cwd would be a second source of truth for two fields that already have one. BACKFILL RUNS FROM postdb:push, so every server picks it up on its next schema push. bun does fire pre/post hooks for colon-named scripts — verified on 1.3.14 before building on it, because a post hook that silently never runs is the worst outcome here. insert-only, exits 0 on every failure path (the push has already applied; a cosmetic backfill must not make it look failed). seeded 11 of 12 transcripts on this machine. it reads the FIRST model, which sounds wrong for a table storing the current one — but switching only became possible today, so no older transcript can hold two, and first costs a bounded head read instead of reading 59MB. also: the family map returns null for mythos rather than a neighbouring family (Project Glasswing only — a picker entry that 401s is worse than none), and ModelSelector no longer picks a default once a conversation has started. an unset picker is the honest state when the server cannot say. tests: the alias map against the five real transcript values, and the selector's default gated on hasStarted. re-broke the guard to confirm it bites. still to do: `model` on the session LIST rows, which the sessions-list revamp wants. the storage and the batched read (getChatSessionModels) are in place for it. |
||
|
|
57bbcca9ae |
fable, and every model alias the CLI actually takes
the claude tier list had three entries — opus, sonnet, haiku — and the CLI accepts nine. `--help` says
`--model` takes either an alias for the latest model in a family or a full name, and the aliases the
INSTALLED cli accepts came out of its own binary with `strings` rather than from memory: default, opus,
sonnet, haiku, fable, mythos, opusplan, and a [1m] suffix on the families with a 1M-context variant.
verified against 2.1.233.
aliases rather than full ids (claude-opus-5) so a new model in a family needs no edit here. the cost is
that what `opus` resolves to moves under you — right for a picker, wrong for a reproducible eval.
mythos is deliberately left out: claude-mythos-5 is Project Glasswing only, and a picker entry that 401s
is worse than a missing one. the comment says how to add it.
opusplan is the one entry that is a MODE rather than a model — opus plans, sonnet executes.
NOT derived from the harness, though it could be: Query.supportedModels() returns the real list with
display names and effort levels, but it is a control request and needs a live session, and this endpoint
is what the composer calls before there is one. noted as open in the file.
the test pins the alias set and the property everything else rests on: claude-manager derives what it
hands the SDK with `split('/')[1]`, so a bracketed alias only works while the id has exactly one slash and
the brackets are unencoded. tidying an id into claude-code/opus/1m would fail as the harness quietly
running the wrong model, not as an error.
|
||
|
|
4c24df93c1 |
a chat can change model mid-conversation
the selector locked on `hasStarted`, so the tier was fixed for the life of a conversation. nothing justified it — the commit that added the flag says only "default model" — and the hazard it resembles is narrower than what it blocked: crossing HARNESS hands an opencode `ses_…` id to `claude --resume`, which reads as a dead agent rather than a wrong dispatch. changing TIER was always safe. so tier is free, harness is pinned, and the server refuses a crossing rather than relying on the UI to. the claude side does it the way the CLI's /model does: `Query.setModel()`, a control request that needs streaming input mode, which is the only mode this file uses. nothing is torn down and nothing is re-resumed — cwd, permission mode and transcript all continue and only the next response changes. an earlier plan here was to kill the session and re-create it with the stored resume id; that would have worked and was unnecessary. opencode already switched per turn (serve-runner POSTs the model to the live session), so only the UI was in its way. FIXES A SILENT MISMATCH while it is here. model reached the SDK once, in createSession's query() options, and every later turn was pushed onto that same call — so a changed model was dropped while session:init, the cost fold and the durable log all reported the new tier. reachable today by leaving a chat idle past the 30-minute GC: the UI said sonnet, opus answered, nothing logged it. the fold now tracks what the harness actually did rather than what the client asked for, and a refused switch reverts the picker. the switch is a durable `model:switched` event and a centred marker in the transcript, because the useful question is the BOUNDARY — which half of this conversation is opus. per-message model was already stamped server-side and shown nowhere, and claude-sessions.ts reads the FIRST model in a transcript, so a switch was invisible in history. tests: turn-stream proves the marker is durable and lands after the previous model's last words; the selector tests pull in opposite directions, so unlocking everything fails one and restoring the blanket lock fails the other. verified by re-breaking both. not done: opencode's `steer` path (a send mid-turn) returns before the model POST, so a switch there applies on the next full turn. session.model also stays stale for opencode tier changes — harness routing is unaffected. |
||
|
|
690a726f55 |
a member's turn ran as the owner after any idle gap
This is the cause. Everything before it was symptom.
`handleChat` had two spawn call sites. The first, for a new session, passed `member`.
The second — "session already live" — was a separate object literal that did not.
That branch is taken whenever OFFICER still holds the session, and officer holds it
long after the SIDECAR has let go: the sidecar reaps a session after 30 minutes idle,
`_claudeKill` stays set here regardless. So a gap sent a turn with no identity to a
sidecar with nothing to push it onto, and it created a fresh session from those
params. No member means the owner.
[claude:spawn:…] resume=49527b33… cwd=…/data/jg@pertento.ai/home as=owner
green is a Developer with their own Linux account, their own `claude` and their own
login. Every turn after a break ran as the OWNER: owner's HOME, owner's Anthropic
credential, owner's MCP config — which carries a JWT that signs as the owner. The
entire per-member boundary, undone by an omitted line in the branch nobody reads.
It surfaced as breakage rather than as silent escalation only by luck. The owner's
`claude` was handed the member's cwd — a 700 home it cannot enter — and the member's
transcript id, so it exited 1 instead of quietly working. Reported as "he went for a
walk and it stopped working", which is the idle timeout to the minute.
── The fix, and what it then found ──
Both paths now build from one `turnParams`, so they cannot drift again.
Then `member` was made REQUIRED-though-possibly-undefined in the protocol, and the
compiler immediately found three more sites that had left it out:
- `agent-handoff/deliver.ts` — the same bug, worse. No member AND a cwd from
`getOwnerHomeDir`, which ignores the email it is given. A member's agent panel ran
as the owner in the owner's home. Now resolves identity like chat does.
- `agents/agent-runner.ts`, `tasks/pipeline-executor.ts` — correct as they were:
both are `kind: 'execution'`, owner-only and never grantable. They now say
`member: undefined` explicitly, because a field you must fill in is a decision and
a field you may omit is an accident.
`resolveTurnIdentity` is exported so "whose turn is this" has one implementation
rather than three.
── What I got wrong on the way ──
Three wrong answers first — the credential, the working directory, then a recovery
whose condition could never fire — each from reading code and reasoning instead of
making the system say what it was doing. The log line that found this took five
minutes to write and should have been the first thing I did, not the fourth.
Also: `--resume` by hand appeared to work twice, which is what sent me down the wrong
paths. It proved nothing. The id I tested was not the id the platform passed.
tsgo clean, 950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
430346e14b |
a restarted job outlives the server that restarted it
The other half of the same report. The pill came back after a refresh, and then a
`pm2 restart officer` killed the dev server outright — because `restartBackgroundTask`
spawned it as a plain child of the platform. A job the AGENT starts is a child of the
`officer-claude-code` sidecar, a PM2 peer, so those were never affected. The tray
therefore showed two chips that looked identical and behaved differently, and the
restart button's own help text promised the opposite of what happened.
Measured against a throwaway PM2 app rather than reasoned about, because the two
plausible causes call for different fixes — a signal to the process GROUP, or a walk
of the process TREE:
plain `sh -c` killed
`setsid sh -c` killed <- a new session is NOT enough
`setsid --fork sh -c` survived
PM2 walks the tree by ppid, so a new session leaves the job a child and it is found
anyway. `--fork` is what matters: setsid forks and its parent exits at once, so the
job is reparented to init before any restart can enumerate it.
── What that costs, and how it is paid ──
The handle `Bun.spawn` returns is now the setsid parent, which exits immediately.
Waiting on it reported every job as finished the moment it started, so exit detection
moved to `watchForExit`: poll who holds the output file, and when nobody does, read
the status the wrapper recorded. That is the same liveness question Stop and Pause
already ask, so it adds no second notion of "running" — and a paused job still holds
its file, so SIGSTOP correctly reads as running rather than as an exit.
Two details that would each have been a bug:
- A GRACE PERIOD. Between the fork and the job opening its output there is a moment
with no holders; polling into it announces a completion for a job that never ran.
- The command is a POSITIONAL PARAMETER to the wrapper, not interpolated into it.
Interpolation would let a command containing quotes terminate the wrapper's own
quoting. Tested with `echo "quoted 'both' ways"`.
A missing status file is reported as a clean finish, not a failure: that is what Stop
looks like, and "you stopped it" is nearer the truth than "it crashed".
Also corrected the button text. It said background jobs survive "because they are
their own PM2 processes" — true of agent-started jobs, false of restarted ones, and
now the wrong reason for both. They survive because they are outside this process's
tree.
── Verified ──
End-to-end through `buildHonoApp`, on a synthetic task:
POST /restart -> 200
holders: pid 906186 (ppid 1, session leader), pid 906188 (ppid 906186)
probe process was 906173 — in neither chain
quotes survived the wrapper
durable events: task:started -> task:notification (completed), exit file "0"
My own assertion in that probe was wrong and the output is what corrected it: it
demanded ppid 1 of EVERY holder, but only the session root is reparented — its own
children hang off it, which is the point.
Not in `bun test`: nothing in the suite touches Postgres or spawns processes.
tsgo clean, 919 pass / 7 fail unchanged. Needs a server restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b7d26a3350 |
a restarted background job survives a refresh
Reported as "I refreshed the page and the pill was gone, but the server is still
running and now I have no way to control it". Not a design choice — a bug, and it
came in with the restart button.
The tray is not stored anywhere. It is a fold over the durable chat event log:
`task:started` minus `task:notification`, per `collectRunningTasks`. That is what
rebuilds it after a refresh.
The restart route announced its respawn with `emitToSession`, which folds into the
in-memory session and writes to open sockets — and never called `appendChatEvent`.
So the pill existed only in the RAM of the browser tab that clicked the button. The
log still read:
seq 10979 task:started "Run dev server"
seq 11362 task:notification failed <- the ORIGINAL dying
Folded on refresh that says "not running", so the chip vanished — while the process
it described was up and serving on 3001. And every task route takes an id, so the
job became unreachable from the UI at the moment the browser forgot the id. Stop,
Pause and Restart would all still have worked; nothing could name the task.
The second half was quieter: `emitToSession` returned false when the session was not
in memory, and the route ignored the return. A platform restart drops the in-memory
session while the job keeps running, so the announcement went nowhere AND was never
written down.
Now it appends first and unconditionally — persistence does not need a listener,
only delivery does — and sends with the seq the log assigned, so a client's replay
cursor stays correct. Both events: the `task:started` is awaited so it is durable
before the client is told the restart worked, and the exit notification is
fire-and-forget because it lands long after the response.
This makes officer a writer of `chat_session_events`, which the note in websocket.ts
said it no longer was. `cut-off` had already been that exception. The real rule is
narrower and is now written down: officer writes events it ORIGINATES, because no
sidecar will. Turn output still belongs to the harness that produced it.
── How it was verified ──
End-to-end, out of band: a synthetic task with a real process holding a real output
file, a transcript in the harness's shape, and the two durable events that reproduce
the reported log. Driven through `buildHonoApp` so the permission gate and auth
middleware were in the path — the last two bugs of this kind both passed tests that
mounted the router alone.
BEFORE restart: 2 durable events -> tray would show 0 running (bug reproduced)
POST /restart -> 200
AFTER restart : 3 durable events -> tray would show 1 running
Not added to `bun test`: nothing in the suite touches Postgres, and this claim is
untestable without it. So this is a probe that ran once, not coverage.
Needs a server restart. tsgo clean, 919 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
92bac490d7 |
attach a PDF and the agent reads it; the microphone button is gone
Two requests, one file apart. ── The microphone ── Removed from the chat composer. The HOOK stays: `useAudioRecording` and `blobToWav` are also the file browser's DictateDialog, which is not what was asked to go. ── PDFs, by not extracting anything ── A PDF entry sat in the attach menu once with no `onSelect` at all — the menu offered it and did nothing, because nothing here could extract PDF text. It is back and real, and it still extracts nothing: the file is uploaded, left on disk, and the agent is given its PATH. Its own `Read` handles PDFs by page, better than any extraction this could have shipped, and it can go back for a section later without paying for it twice. The alternative was base64 in the message. That is megabytes in the turn, re-sent for the rest of the conversation, to produce worse text. ── What this uncovered ── `attachmentIds` has been on this websocket and in `types.ts` for a long time and NOTHING on the server read it. Images work because they travel separately as base64, so the ids were dead on the wire — and a PDF is exactly the case that needs them. They resolve to paths now, appended to the prompt as `Attached files (read from disk)`. Ids arrive in a websocket message and become a filesystem path, so they are checked rather than trusted: only the `<uuid>.<ext>` shape uploads actually generate, and only in the two directories uploads actually write to. Verified — a valid id resolves, an unknown one yields nothing, and `../../../../etc/passwd` and `; rm -rf /` yield nothing. Both upload directories are searched because the first message of a chat carries ids written to `tmp/` (no session yet) and later ones carry ids under the session. PDFs may be 25 MB against an image's 5: nothing carries them into a prompt, so the ceiling is storage rather than context. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. `[open]` For a MEMBER the file lands under `data/<email>/attachments/`, and whether their Linux user can read it depends on the confinement ACLs. Untested — the owner path is what was verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bda5eb15de |
a job's controls can no longer freeze the platform
The server went down. It had not: it was STOPPED — eleven threads in state T, port 9000 still listening with connections queueing behind a process that would never accept them. Indistinguishable from a crash unless you look, which is why it took a terminal to see. `kill -CONT` brought it back with nothing lost. I did it, with one missing line. `restartBackgroundTask` opened the job's output file to hand the child a descriptor and never closed it, so the PLATFORM became a holder of that file. `pidsHoldingFile` asks `fuser` who holds it — that is how Stop finds a job with no recorded pid — so the platform was in the answer. The next Pause sent SIGSTOP to it. Every part worked as designed. Pause froze exactly what was holding the file, and one of those things was the thing doing the freezing. Two fixes, and the second matters more than the first: **Close the descriptor.** The child gets its own duplicate at spawn, so closing costs it nothing. That removes this instance. **Never signal our own pids, whatever `fuser` says.** `pidsHoldingFile` now filters `process.pid` and `process.ppid`. The leak is fixed; this is what makes the next one survivable. Nothing that manages a job should be able to name the process doing the managing — a rule worth having independently of the bug that suggested it. Verified: after a restart this process holds the output file zero times, `fuser` returns only the spawned shell and its child, and our own pid is filtered even when present. tsgo clean, frontend builds, 901 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
19e75651e7 |
a restart runs the job in its own directory, or not at all
Restarting a `bun dev` belonging to a project in ~/dockers/officer-atelier started the PLATFORM's dev server instead. The respawn passed no cwd, so it inherited the server's own working directory, where `bun dev` means something else entirely. The result was not an error. `bun --watch src/server.tsx` came up and bound port 9000 ALONGSIDE the running platform — Bun allows that through SO_REUSEPORT — so requests were split at random between two instances of the platform. Confirmed on the machine by ancestry: `sh -c bun dev` → `bun dev` → `bun --watch src/server.tsx`, with two listeners on 9000. Stray killed; the real server was unaffected. The information was there all along: every transcript entry carries `cwd`, and the entry holding the tool call is the one that says where it ran. `commandForTask` returns both now. And when there is no cwd it REFUSES, rather than falling back to the server's own. The fallback is what produced this, and it produced the worst outcome available — not a failure, but a different program running successfully. A command is only meaningful with the directory it was written for. Verified with a job whose transcript says it ran in /tmp/cwd-probe-app: the restart reports that directory and the respawned process prints it, reading a file that exists only there. tsgo clean, frontend builds, 898 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
73eb128fb1 |
pause, resume and restart a background job without asking the agent
Three buttons in the expanded task panel, beside Stop. Not on the chip: the panel is where you decide what to do with a job, and a row of unlabelled glyphs on a pill is where you misclick. ── Pause / Resume ── SIGSTOP and SIGCONT — the only pair that means "stop doing things and keep everything". The process keeps its memory, its open files and its bound port, and picks up mid-instruction. Signals go to every pid holding the job's output file, because for a shell job that is the shell AND what it spawned, and freezing one of them would be worse than freezing neither. Verified by watching a job that writes a line a second: 3 lines, frozen at 3 across three seconds of pause, 6 after resume. Worth knowing before pressing it: a paused server does not REFUSE connections, it stops answering them, so a browser pointed at it hangs rather than erroring. That is what pausing is, not a shortcoming here. ── Restart, and the cost it accepts ── The command is recovered by joining the transcript to itself: the harness records the `tool_use` carrying `command`, and separately its result — the text `Command running in background with ID: <id>`. Neither alone is enough; together they are a join on `tool_use_id`, and that is the only place the command survives. The transcript is found FROM the output path rather than searched for, since a task file at `<tmp>/claude-<uid>/<slug>/<session>/tasks/<id>.output` names both. The respawned process appends to that same output file. That is what keeps everything else working untouched — the log view reads that file, and Stop finds the process by asking which pids hold it open. The real cost, chosen deliberately: the platform becomes a SECOND owner of a task. The harness stops watching when its own child dies, so the platform supervises what it spawned and emits the same `task:started` / `task:notification` pair everything downstream is built on. `emitToSession` is the seam, and it is the only one — nothing else learns that a task can have two kinds of parent. A restart re-announces an id already in the transcript, so `task:started` now RESETS an existing task row rather than appending a second one. Without that the tray showed the job twice, once finished and once running. Restart is offered for a finished job too, which is the case that matters most: a dev server that fell over is exactly what you want back without typing a sentence at an agent that may be mid-turn. Verified end to end against a planted task: command recovered, old process killed, new one spawned, output APPENDED (`first run output` then `RESTARTED-OK`), exit reported through the callback, supervision registry released afterwards. `[open]` A restarted job does not survive an `officer` restart — the platform is its parent now. The harness's own jobs never did either. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bf44a4cfab |
resolving a member's task symlink needs their identity too
One layer below yesterday's fix, and it looked identical on screen: the job was
found, then declared to have written nothing.
`loadBackgroundTask` resolved the located path with `realpathSync`, as the
platform. Resolving a link needs `x` on every directory above it, and
`/tmp/claude-<uid>` is `drwx------` — so it threw EACCES on an INTACT link, and
the catch below it reported that as a dangling symlink and returned null. The
tray polls, gets `{kind:'pending'}`, and shows "Nothing written yet" forever.
The lookup fix could not have exposed this: it made the file findable, which is
precisely what let execution reach the line that then threw it away.
`realpath -e` rather than `readlink -f`, because `-e` requires every component to
exist — that keeps a genuinely dangling link reporting as one instead of
resolving to a path that is not there. Verified against symlink, plain file,
dangling link and missing path: `realpath -e` and `realpathSync` agree on all
four, so the owner path is unchanged.
Audited the rest of claude-sessions.ts for the same shape. Every remaining
mention of `statSync`/`readdirSync`/`Bun.Glob` in that file is a comment
explaining why it is NOT used.
Live coverage extended: the member resolves their own link, the platform gets
null. Skips without OFFICER_TEST_MEMBER.
tsgo clean, frontend builds, 865 pass / 7 fail unchanged, 7 skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6bee6b856e |
a member's background job can be read and stopped
A member started `bun dev`, watched it run, and got no logs at all — then
`{"stopped":false,"reason":"no such task"}` from the Stop button. Two symptoms,
one cause, and it is the same one `read-as-user.ts` was written for.
`findTaskOutput` globbed `/tmp/claude-*/…` as the platform's own uid.
`/tmp/claude-<uid>` is `drwx------` (checked, not assumed), so for a member that
directory cannot even be traversed. A glob that cannot traverse does not throw —
it returns an empty iterator. Both callers read that as "no such task": the log
view showed "Nothing written yet" forever, and Stop refused an id that was
perfectly real.
Every other filesystem access in that module already goes through the as-user
helpers. This one path never did, and it was invisible for as long as only the
owner used chat.
So the lookup moves into `findPathAs`, and Stop runs as the member too — both
halves of it. `fuser` reads `/proc/<pid>/fd`, which only the process's own user
and root may open, so as the platform it would have found no pids and reported
the job already stopped; the kill after it would have been cross-user EPERM,
swallowed by a catch. Neither would have said anything.
── The exit code is ignored, and that is the point ──
The first version of this fix located the file and threw it away. `find` exits 1
when it cannot descend into something, and searching `/tmp` from ANY identity
hits another account's `drwx------` — it prints the match on stdout first, then
returns 1. Treating that as failure reproduced the original bug through a new
mechanism. Caught by testing the owner path against a real task file rather than
assuming the refactor was behaviour-preserving; a missing root still exits 1 with
empty stdout, so absence still reads as absence.
Owner and member now take the same `find`, deliberately: one set of matching
semantics, rather than a glob for us and a find for them, which is how a bug ends
up reproducing on one account and not the other.
Verified on the owner path here against a live task file. The member path is
covered by `read-as-user.live.test.ts` — same opt-in shape as
`spawn-as-member.live.test.ts`, asserting the two identities DISAGREE, which a
world-readable tree would not satisfy. It cannot run on a machine with one
account, so it skips here and needs OFFICER_TEST_MEMBER on the host.
tsgo clean, frontend builds, 865 pass / 7 fail unchanged, 3 new skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
048e718b21 |
a stop button on running background jobs
Killing a job meant asking the agent in words — a whole turn to say "please stop
that", and only possible while the agent is responsive. Now it is a button on the
chip.
── Finding the process ──
The platform does not own these processes. Claude Code starts them and nothing
records a pid. What it has is the file the job streams into, and the job is still
holding that file open — so `fuser` gives the pid, and the file is resolved
through the SAME scoped lookup the read path already uses: the id is validated
against TASK_ID_RE, then matched only under this caller's own directories. An id
that resolves to nothing kills nothing.
Verified end to end rather than reasoned about: a stand-in job holding its output
file open, `fuser` finding it, `{"stopped":true}`, process gone.
SIGTERM, not SIGKILL — a dev server with children should get the chance to take
them down with it. SIGKILL orphans them and leaves the port bound, which is worse
than a stop that takes a second.
── Two things it deliberately does not do ──
It does not dismiss the chip or write a `stopped` status locally. The real end
arrives the way every other end does — the harness sees the exit and the
transcript gets the row — so faking it would be a guess that usually agrees with
reality and sometimes does not. A stop that fails leaves the chip running, which
is the truth.
It does not offer to stop a SUBAGENT. Those write a `.jsonl` transcript rather
than holding a log open, so there is no fd to trace; it says so instead of
pretending.
`Square`, not `X`. X on the same chip means "close this", and confusing "hide the
chip" with "kill the process" is the one mistake worth designing out — which is
why stop appears only on running tasks and dismiss only on finished ones.
tsgo clean, frontend builds, 851 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2e8ec845c8 |
step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
|
||
|
|
e13128846b |
offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
327783532e |
reach a member's transcripts by listing them, not just by reading them
yesterday's fix routed transcript CONTENT through the member's identity and
stopped there, on the strength of a comment in ChatIdentity saying enumeration
never needed it — "their directories are 775 and the platform holds an ACL
entry, so readdirSync and statSync have always worked".
there are no 775 directories on this path. claude creates ~/.claude/projects/
and every project group at mode 700, and a 700 directory clamps the ACL mask to
--- exactly as a 600 file does:
user:officer:rwx #effective:---
mask::---
measured against a real member home:
existsSync(projects) -> true (stat only needs traverse on .claude)
readdirSync(projects) -> EACCES
existsSync(projects/<slug>) -> false
statSync(<transcript>) -> EACCES
existsSync answering false rather than throwing is why this was invisible: every
caller read it as "no such session". one root cause, three reported symptoms —
an empty conversation list, no title on a new chat, and a /chat/<id> deep link
that never restored the conversation. a fourth nobody had reported yet: delete
removed nothing and still answered ok, because unlink needs w+x on the group
directory too.
so enumeration goes through the same door as content, as ONE call rather than a
spawn per entry: listTranscriptsAs runs a single `find` as the member and
returns every transcript with its mtime, which readdir+stat could not do without
dozens of setpriv forks per request and a matching pile of auth.log lines. the
owner keeps a fork-free path — that process already IS the owner. removeAs does
the same for unlink, and readTailAs no longer stats a file it cannot stat.
summarizeTranscript now takes the mtime it is given instead of stat'ing again,
which is both the fix and one less syscall per file.
verified against jg@pertento.ai on this machine: 6 conversations listed with
titles from their first prompts, and a deep link by id alone loads 71 messages.
owner path re-checked and unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1292a5c5ab |
opencode is owner-only until it carries an identity
a turn on the opencode harness ran as the owner, in the owner's home, whoever asked. handleOpenCodeChat resolves its cwd against getOwnerHomeDir(email), which discards the email it is given, and the sidecar runs one shared `opencode serve` as the service user — sendOpenCodeStreaming accepts userId/email/username and forwards none of them. it carried a comment calling itself owner-only; nothing enforced it. reachable by any account with the `chat` grant, which every role holds by default (DEFAULT_ROLE_CAPABILITIES), and isClaudeModel is a startsWith, so a typo'd model string landed there too. the model is client-supplied and never checked against the catalogue. the same gap on the read side: opencode's session store has no per-user scoping at all, so loadOpenCodeSession/delete/rename take an id and no identity, and the list and live routes returned other people's conversations. so: ChatIdentity carries isOwner as its own fact (not inferred from osUser === null, which holds only while resolveHomeDir refuses a member without one), and every opencode door in chat.ts checks it — list, load, live, delete, rename — plus a refusal on the execution path in handleChat. /chat/models hides opencode from non-owners as a courtesy; the socket refuses regardless. a stopgap, not a design. the fix is to thread identity through the opencode sidecar the way spawnClaudeAsMember does, and TODO.md has been saying so. not fixed here, and worth knowing: a member's session list is still empty and /chat/pwds still 500s, because readdirSync on their ~/.claude/projects is EACCES — claude creates it at mode 700, which zeroes the ACL mask. visible in officer-error.log right now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
336e718463 |
read a member's transcripts as the member
a provisioned member could chat normally and had no conversation list. every
refresh came back empty, so nothing could be resumed, and a new chat never
became a saved one.
nothing was wrong with the logic. the turn runs as them, writes its transcript
into their home, and the platform looks in exactly the right place — it just
cannot read what it finds.
confineUserTree grants the service user a named acl entry on every member home,
with d: defaults so anything created later inherits it. that entry is real and
getfacl shows it. it does not survive a file created at mode 600, because posix
derives the acl mask from the group bits of the creation mode:
user:officer:rwx #effective:---
mask::---
claude writes every transcript at exactly that mode — .claude and projects/ are
775, every *.jsonl is 600. so readdir and stat worked, every read raised eacces,
and summarizeTranscript catches eacces and returns null. the sessions did not
fail, they vanished.
no acl can fix this. the creation mode ands the mask down, so d: defaults cannot
raise it, and the only way up is through `other`, which is every account on the
box. a 600 file has two readers: its owner, and root.
so read as the owner of the file, through the same runAsArgv the terminal and
the agent already use. spawnSync keeps it synchronous, which is what lets it
drop into a 914-line synchronous parser reached from five modules instead of
rippling await through all of it.
the privileged surface turned out to be seven call sites, not the file: stat
needs traverse and readdir needs read, and the 775 directories give both. only
content needed identity.
also fixes a 500. parseClaudeTranscript read the file uncaught after an
existsSync that passes, so deep-linking /chat/<id> as a member threw rather than
404ing. it returns null now, like the list path always did.
verified against a throwaway linux account provisioned the same way a member is
— 700 home, named acl, transcript written as them at 600. before: 0 sessions and
loadClaudeSession null. after: the session, its title, its messages, and a
rename that leaves the file owned by the member at 600.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
571d0a62ff |
delete the bug-report discord webhook, and the last dead HOME_DIR reads
DISCORD_BUG_REPORT_WEBHOOK is gone, with sendToDiscord and its helpers. Reports still land in DATA_PATH/bug-reports — the disk write always happened first and the webhook was only a ping about it, so nothing about the report is lost. It was a personal notification channel living in deployment config, on a platform whose owner is the only person who files reports. It was also never in .env.example: the setup script wrote a variable nothing documented, which is the same drift as PORT, in the other direction. Note DISCORD_WEBHOOK_URL is a DIFFERENT variable — the notify sidecar's own channel — and is untouched. Then a parity sweep of setup / .env.example / what the code reads, which turned up two leftovers from earlier today: HOME_DIR was still read in six files, each with its own `?? homedir()` fallback. Dead since nothing sets it, but a dead read is worse than none — it reads as a supported override. They take homedir() directly now. user-instance.ts gets a comment on why its line stays where it is: it sits above `process.env.HOME = homeDir`, and homedir() reads $HOME, so a read moved below that assignment would return whichever member was last spawned into. Two of the six had fallback chains ending in process.cwd() and '' — the second would have silently disabled whatever consumed it rather than failing. VAULTWARDEN_URL was uncommented in .env.example among the variables setup writes, though it is a plugin variable setup has never written. Commented out with the other plugin entries. The three files now agree: setup writes PORT, BROWSER_RELAY_PORT and POSTGRES_URL; .env.example lists those plus JWT_SECRET and VAULT_STORE_KEY, which are required by code and deliberately unwritten until the secret store lands. Not typechecked (empty node_modules, frozen installs). Every changed file parses; the setup section was run and writes three variables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3c7f52ab77 |
PORT is read in one place, and it has no default
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.
It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.
It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.
It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.
server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.
Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e72cae4830 |
one default port, and it is 9000
Every PORT fallback in the tree now says 9000. There were three answers to one question, each defensible where it was written and none of them visible from the others: server.tsx and 19 sidecars 5000 a default from before there was an installer user-instance.ts 9010 what scripts/setup-old/setup.sh really wrote .env.example 9000 what we told people to write 5000 goes first because macOS binds it — AirPlay Receiver has owned it since Monterey, so a dev server there fails to bind or gets shadowed by something that answers. All 22 sites moved together, which is the point. Changing the app alone would have turned a consistent-but-wrong default into a split one: the app on 9000 while nineteen sidecars still dialled 5000. 9010 was the interesting one. It was the only value that ever matched a real machine, because it is what the old installer wrote — and it was in the single file whose disagreement would have broken chat alone, with nothing else looking wrong. Its own comment records the same bug being fixed once already, within the file, by a change that left it disagreeing with everything outside it. Note what these defaults actually are: the sidecars bind nothing. user-instance.ts has no listener at all — it builds ws:// and http:// URLs that both address the app's single listener. So every one of these numbers is a guess at where the app is, for a value that .env always supplies. Worth removing rather than aligning, which is a separate change. Not typechecked (empty node_modules, frozen installs). Every edited file parses under `bun build --no-bundle`; the pm2 profile loads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f071c0b24 |
the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and VAULT_STORE_KEY are no longer written by the setup script. data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/, capabilities/ and dockers/ as fixed names under it. The direction used to run the other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in app-store/paths.ts — which meant three environment variables that had to agree with each other and with the tree on disk. Eight files re-read process.env.DATA_PATH independently, each with its own `?? cwd()/data` fallback. They import the one value now, which is what made removing it safe: otherwise each would have derived its own and drifted. Three things this turned up. The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a comment asserting "__dirname is the repo root — this file sits beside ecosystem.config.cjs", which stopped being true when these files moved into ecosystem-files/. It walks up to the platform's package.json now, which holds wherever the file lives. That was a live bug before this change and a load-bearing one after it, since cwd now decides where the install is. assertInstallLayout joins the other two boot assertions. A wrong cwd does not error — it computes a plausible root somewhere else and writes managed homes and agent runs into it, so the install looks empty and the data looks lost with nothing naming the cause. It throws before serve(), first of the three, because a wrong answer there makes the other two check the wrong files. getOwnerHomeDir captures homedir() once at module load rather than per call. Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME when set rather than reading passwd, and user-instance.ts assigns process.env.HOME on its way to spawning an agent. A lazy read would have returned the owner's home on the first call and a member's afterwards. data-path.ts imports only node builtins, so it is evaluated before any of that runs. JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script does not boot — jwt.ts throws at module load without one. That is the agreed sequencing: they move to the SQLite store (docs/secret-store.md), and writing them here meanwhile would create a second origin for a secret the store then has to be reconciled with. Said plainly in .env.example and in lib/env.sh rather than left to be discovered. Not typechecked: node_modules is empty here and installs are frozen. Every edited file parses under `bun build --no-bundle`; the profile loads and pins the right cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx is not the pinned resolution and reformatted unrelated unions and line wraps in six files, so those were reverted and the edits re-applied by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ec1997fd0e |
a chat with no chosen directory runs in the caller's own home
The default was DATA_PATH/<email>/general_chat_sessions, a dedicated directory so /chat sessions formed their own Claude project group instead of cluttering the home. It is a sibling of the home, and confineUserTree makes every sibling the platform's at 0700 because the others are attachments and email_accounts. So it was unreachable for a member: the first live member turn started there and every Bash call failed on its own working directory before doing anything. A per-member copy inside each home fixed the symptom and left two rules to remember. The owner chose one rule instead — the account's own home, whoever they are — and accepted the trade knowingly: /chat sessions now share a project group with anything else run from that home, which was the reason the dedicated directory existed. Removed rather than left dangling: getGeneralChatSessionsCwd, ensureGeneralChatSessionsCwd, ensureMemberChatCwd, and general_chat_sessions from USER_DIRS so new accounts stop getting it. Existing directories are untouched and their transcripts stay where they are — Claude groups by cwd, so the owner's old /chat history remains under its own project slug rather than moving. The UI labels move with it: the default group now reads "home" rather than naming a directory that no longer has a role. ChatIdentity keeps carrying both email and home. The pairing was justified in the comment by general_chat_sessions being email-derived, which is now gone — but the distinction it encodes is real (the email says who, the home says where), so the comment explains that instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c84c74c91 |
give a member a chat cwd they can actually enter
host captured the first live member turn: uid 1001, nine env vars, zero ANTHROPIC_*, zero POSTGRES_URL, zero JWT_SECRET. The privilege drop and the allowlist both held. One defect. The default chat cwd was DATA_PATH/<email>/general_chat_sessions — a sibling of the member's home, which confineUserTree deliberately makes the platform's at 0700 because the other siblings are attachments and email_accounts. So the turn ran in a directory the member cannot enter, and every Bash call failed on its own cwd. The agent reported its shell as broken, which was true. A member's default is now ~member/general_chat_sessions, created as them through runAs. mkdir -p, so it is idempotent per turn and needs no reprovision. The owner's path does not change, and the sibling stays 0700 — loosening it would trade a broken shell for an open directory holding attachments and mail. 29 said this path is email-derived and therefore stays email-derived. True, and it did not follow that it is usable: an email-derived path under DATA_PATH is precisely the set a member is locked out of. Splitting identity from filesystem path was right; assuming the identity side was inert was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
532ad15ac1 |
drop the import the gate left behind
|
||
|
|
c59df4f866 |
let members use chat
The owner authorized this explicitly. Two refusals removed together, because
they were always one guard in two places: the wholesale isSuperAdmin middleware
in api/chat/chat.ts, and the chat socket's 403 in server.tsx.
They were right for the day they stood. A turn spawned claude as the OWNER and
every transcript path resolved through the owner's home, so a granted member
would have read the owner's sessions and run an agent as them.
What replaced them, rather than what deleted them:
the turn runs as the member spawnClaudeAsMember through sudo setpriv,
proven against a real account by reading file
ownership rather than trusting the process
the credential is theirs --reset-env plus an allowlist, so the owner's
proxy variables cannot cross
the transcripts are theirs ChatIdentity carries a home from resolveHomeDir
and claude-sessions cannot invent one
the sessions are theirs every session records its owner and all six
sidecar commands refuse a mismatch
Also adds the precondition host asked for in 10: a member whose claude is not
signed in gets the instruction rather than a turn that dies on an auth error and
reads as a broken agent. Not installed and not signed in are separate messages
because they need different actions.
registry.ts and registry.test.ts now describe chat as confined in fact rather
than ahead of its implementation. The comments at both former guards say what
had to exist first, and that a revert should go back to a refusal rather than to
a narrower one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1575df3f78 |
stop building cwds out of an email address
host found a live regression from |
||
|
|
95951fbe5a |
resolve transcripts and cwd against the caller's home, not the owner's
The history layer, and the last change that could be made without a live member.
claude-sessions.ts had `claudeHome = process.env.HOME_DIR ?? join(DATA_PATH,
email, 'home')`, which discards its argument whenever HOME_DIR is set — always,
on a real install. Every transcript read therefore resolved to the OWNER'S
~/.claude no matter who asked, and the comment above it asserted "single-user
platform" as though that were a property rather than an assumption. A member
reaching these functions would have been handed the owner's conversation list.
Now every read takes a ChatIdentity {email, home} with the home resolved from
resolveHomeDir(userId), and this file has no way to invent one. Both fields
travel together because they are genuinely different: general_chat_sessions
lives under DATA_PATH/<email>, not under a home. Collapsing them would be the
same class of mistake as undefined meaning "the owner".
websocket.ts's resolveCwd takes a home, so `~` expands against the caller's own.
Identity is resolved BEFORE the cwd — expanding `~` before knowing whose home it
is would be exactly the bug being removed — which also let a duplicate
resolveTurnIdentity call from
|
||
|
|
6aeb304f56 |
never spell "I don't know whose turn this is" as "the owner"
host caught that resolveMemberRun failed open. Returning undefined means "run as the server owner" downstream — their binary, their ~/.claude credential, their HOME, their MCP config carrying OFFICER_AUTH_TOKEN — and three different inputs produced it: the caller being the owner, resolveHomeDir failing, and a member whose osUser is null. The last two mean "could not determine", and answering them with the owner's identity is the single thing this feature exists to prevent. 23's own comment said the caller must not fall back to the owner. The code did exactly that. The prose was right. Now a discriminated TurnIdentity: owner, member, or refuse-with-a-reason. The call site ends the turn on refuse instead of spawning. The owner's identity is reachable only by positively establishing isOwner, never by failing to establish anything else — resolveHomeDir already reported it as a positive fact and the funnel through undefined was the only thing discarding it. The null-osUser case is not hypothetical: provisionOsAccount is non-fatal at every stage and records the account either way, as its own source says. Tonight provisioning failed three separate ways on a real member and the account survived each time. No test yet, and the reason is in COMMS rather than hidden: it needs database fakes this repo has no pattern for, and inventing one at 01:00 to cover four branches is how the next defect gets written. The union is exhaustive, so tsgo catches a missing case — not the same thing, not nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
311b2ea55c |
populate member from the authenticated socket
The last mechanical link: chat socket -> resolveMemberRun(userId) -> ClaudeSpawnStreamingParams.member -> claude-manager's branch -> spawnClaudeAsMember -> sudo setpriv. The path from a request to a privilege drop is now complete. Resolved from the authenticated socket, never from the client message — the same rule server.tsx applies to the pty sidecar, where it deletes any client-supplied osUser/home from the query string before setting its own. resolveMemberRun returns undefined rather than throwing when a home cannot be resolved, because undefined means "the owner" downstream: an account with no Linux user has nothing to confine a turn to, and falling back to the owner is the one wrong answer that must not happen by accident. A separate function with that reasoning attached rather than an inline ternary. Still inert. Both gates refuse non-owners before this line is reached, so the only path that reaches it today returns undefined via isOwner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d59adbf1f2 |
scope the six sessionKey commands to their caller
The control surface half of
|
||
|
|
e4acf19a35 | Merge remote-tracking branch 'gitea/master' into sidecar-app-store | ||
|
|
4d4a253f72 |
terminal runs as the member; chat is grantable and still refused
TERMINAL is confined now, and the shell is genuinely theirs. The pty sidecar spawns it through sudo setpriv as their own account, in their own home, with the platform's environment cleared. Verified end to end against the sidecar's own socket: id -u 1001, not 1000 file the shell wrote owned by ptyprobe ps -o user=,args= ptyprobe /bin/zsh -i env | grep -c POSTGRES 0 osUser and home are resolved in upgradeWs from the authenticated account, and whatever the browser sent under those names is DELETED first. The bridge forwards the query string to the sidecar untouched and the sidecar starts a shell from what it finds there, so trusting the client for either would let a member ask for the owner's uid in a query parameter. node-pty does support uid/gid, unlike Bun.spawn, and they are deliberately unused: they set the ids without applying the account's groups or resetting the environment, so the shell would keep the owner's groups and everything Bun loaded from .env. Also closes the pty identity blindness in TODO.md. Sessions record whose they are, list and kill scope to the caller, and re-attaching to a session belonging to another account is refused — otherwise a member resumes someone else's shell by guessing an id that travels in a query string. Measured: member killing the owner's session -> ok:false, owner killing it -> ok:true. CHAT is confined so the owner can grant it and the route resolves, and both execution doors refuse a non-owner: the router wholesale, and the socket in server.tsx. The agent has not moved — the SDK spawns claude itself with nowhere to put a uid, and every transcript path resolves through the owner's home, so a member would read the owner's session list and run an agent as the owner. Reads are refused too, because listClaudePwds returns the names of the owner's projects. A deliberate, temporary gap at the owner's request: permission and route now, function when a turn can be spawned under runAs with the member's own HOME. Both guards say so, and the registry test names them so a future edit cannot move one without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
adaaba658c |
list opencode sessions from every project, not just the serve's own
An opencode session in a git directory never appeared in /chat. The list read GET /session,
which answers for ONE project — the one the request's directory resolves to, and with no
x-opencode-directory header that is the serve's own cwd, DATA_PATH/opencode_server. Not a git
checkout, so it resolves to the catch-all project `global`, along with every other non-git
directory. That is why the default chat dir listed fine and nothing looked broken: a cwd that
IS a checkout gets its own project, and chat pwds are checkouts.
Measured on the live serve before changing anything: /session returned 8 sessions, /api/session
13, the five missing ones being an old project's. A session created in a git directory came back
0 times from /session and 1 from /api/session.
/api/session spans projects, so that is now the list. The per-id reads stay on /session — they
answer for any session regardless of project, verified 200 with and without the header.
The trap, and the reason listSessions normalises rather than returning the response: the two
surfaces disagree in silence. /session carries the working directory as top-level `directory`,
/api/session as `location.directory` with no top-level field, inside a {data: …} envelope.
Swapping the endpoint without the mapping leaves `directory` undefined on every session, which
the cwd filter turns into an empty list — the same shape as the metadata.officer.cwd bug this
filter already had once.
Verified against the live serve: with the mapping, a session in a git directory and one in the
general chat dir both resolve to their cwd, and every session carries a directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d9857eef7c |
re-land: deliver a chat turn to every socket watching it, not the newest one
This is |
||
|
|
0a4ff548b9 |
revert the chat tabs and panes work, back to one conversation
Andre asked for zero, not another fix on top. Reverts ec4f06a..7726c9f — the ten commits from "tabs and panes" onward: the tab bar and pane splitting, tab renaming and its page title, the per-server directory picker, the render-loop fix, pane transcript resolution, the send queue, the two socket fixes from the other session, the pane-socket notes, and my own socket-set change from tonight. He is rebuilding from here. Deliberately KEPT: |
||
|
|
7726c9fc71 |
let every client watching a chat receive it, not the newest one
Reported from two devices at once: typing on the iPad, reading the reply on the Mac. Sending from the Mac produced nothing there. Both halves are one field. A session held `ws`, a single socket, and `attachWs` assigned it. So the newest attach silently took the turn away from whoever was already watching — and with a tab now holding up to three panes, plus a phone and a laptop on the same conversation, several sockets per session stopped being exotic and became the ordinary case. Now a Set, and every message goes to all of them. `detachWs(sessionId)` was worse, because it named no socket: it nulled the field on ANY close. A stale client going away therefore killed delivery for the client that had attached after it, which is the "nothing happens on the Mac" half. It takes the socket now and removes only that one, and the idle GC is armed only once nothing is left watching — otherwise a close would collect a session another pane is still reading. endTurnIfAgentIsGone takes the whole set for the same reason: a cut-off notice explains a spinner that will otherwise never stop, and telling one of three clients leaves two spinning. Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing identically on master before this change. Nobody has clicked it; the two devices that reported it are the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |