Commit Graph
53 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 0c2ff88c88 a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat
rather than navigating to Command HQ — right, and it cost the composer its commander controls, because
`isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander,
rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a
watcher wants, and the one the dialog makes default inside a commander.

GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same
isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation.

The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed.
The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route
to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only
would have broken the brand-new composer, which is where the whole thing starts.

Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and
isCommander=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:29:35 +00:00
pastilhasandClaude Opus 5 ebfa09e067 live splits into Agents and Commanders, and the payload says which is which
A commander is a chat by every mechanism it uses, so it turned up in Live beside the conversations
somebody is actually having. With one deploy commander that is untidy. With a watcher armed per repo it
is a list where the thing you are looking for is outnumbered.

Two tabs inside Live, counts always shown. That last part differs from the badge on the Live tab above it,
which hides at zero because a standing `0` there says nothing. These two say a lot at zero:
`Commanders (0)` is how you find out the watcher is not running, which is usually why you looked.

The flag is derived server-side from the cwd, not from a `command_hq_sessions` lookup — every commander
shares one directory, so the directory IS the answer, it costs no query, and it is already right during a
commander's first turn, before any row exists.

It has to be in the payload rather than computed in the panel, and that is the point of the change: the
iPhone app swipes between LIVE chats and has no room for tabs. Filtering there needs the server to name
them. A deploy commander appearing in that swipe order is precisely what must not happen.

A commander row also now opens Command HQ instead of /chat. Same transcript either way, but /chat renders
it with nothing that makes it a commander legible — no category, no cost, no watcher state, and no way to
disarm it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:25:17 +00:00
pastilhas 12c5e51ce4 nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a
turn, and at 3am nobody types the next message. this is the schedule that does.

the door already existed. `startClaudeTurn` takes `ws: null` — extracted that
way so command-hq could drive a turn over http — so this is a second caller of
an existing path rather than a new way into a live session. i had said the
platform lacked that; it did not.

the rule that decides the design: never speak while the agent is working. so
`every_seconds` is a FLOOR, not a metronome. when a nudge comes due and the
session is generating, the row is left alone and reconsidered next tick, which
means it fires as soon as the turn ends rather than sleeping through to the
next hour. a strict alarm would skip an agent that finished four minutes late
and leave it idle for the other fifty-six.

`isGenerating` unknown counts as BUSY. a sidecar that cannot answer might be
mid-turn, and interrupting one is the failure this exists to avoid.

matched on both sessionKey and claudeSessionId: officer's handle and the
transcript's filename are different ids, only the agent holds the mapping, and
a schedule stored against one would silently never match a live row keyed by
the other.

one nudge per (user, session) — a second schedule for one conversation is two
alarms for one sleeper. counted even when the turn fails to start, so a
refusal cannot become a tight loop. disables itself at the ceiling rather than
leaving a spent row armed. the tick starts in server.tsx rather than on first
use, because a schedule armed before a restart has to survive one.
2026-08-20 03:07:28 +00:00
pastilhas 72d327e360 bell: scope the preference to a user, and make every_turn mean every turn
review of 73cdc2cb before building the phone half on it. three changes, and
the third is the one that matters for mobile.

1. "every end of turn" was silent when the agent asked you something.
   the two kinds are disjoint at the source: turn-ended fires on
   result/error/stopped, input-needed at a plan or question gate — which PARK
   the turn and never emit a result. matching them one-to-one meant whoever
   picked the most inclusive setting got nothing for the most urgent event.
   every_turn is a superset now.

2. the preference is keyed on (user_id, session_id), not session_id alone.
   the route skipped an ownership check on the reasoning that a preference for
   a session you cannot reach is inert. it was not: delivery targets the
   userId the sidecar reports while the preference was read globally, so any
   account holding `chat` that knew a session uuid could set 'none' on someone
   else's conversation and switch off their notifications, or every_turn to
   make their phone buzz all night. surrogate id plus a unique INDEX rather
   than a composite primary key, which drizzle-kit re-creates on every push.

3. the decision moved out of the browser socket into notify-decision.ts.
   it lived inside notify-delivery.ts, which was fine while the browser was
   the only listener and stops being fine the moment push subscribes to the
   same signal. two copies of the rule drift, and the symptom is a bell and a
   phone disagreeing about one conversation — close to undebuggable from
   either end. both channels ask the same function now, and the rule is pure
   so it is tested without a database.

the rest of the commit reviewed well: the ws upgrade is authenticated AND
permission-gated, there is a registry entry so totality passes, the list query
is batched rather than per-row, reads are defensively caught, and
Notification.requestPermission is requested lazily on opt-in.

alpha was missing the table entirely — reads degraded via .catch but the PATCH
would have 500'd. pushed there too.
2026-08-19 21:53:11 +00:00
pastilhas 5cba1b65a6 notify: only "the app is gone" deletes a device row
reported from the field on the first real push: it failed with BadDeviceToken
and the server deleted the registration. the build was production-signed and
the row said sandbox — a configuration mismatch, with the device and the app
both perfectly fine.

pruning there destroys the evidence. the phone re-registers on next launch,
the row comes back looking healthy, and the next send fails identically; you
can lose a day to that loop without ever seeing what is actually wrong.

so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune —
apple and google are saying the app is uninstalled. BadDeviceToken,
DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and
SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check.
everything else is transient and still counts toward three strikes.

INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed
MESSAGE — including a non-string in `data`, which is the mistake this codebase
is most likely to make and already has a test for. deleting on it meant one
bug in the payload builder would wipe every android device from the registry
on a single send, presenting as "android push stopped working" with nothing
left to explain why.

misconfigured is also not counted as a strike. not deleting is not enough on
its own — a persistent mismatch would reach three failures and delete the row
anyway, which is the same bug with a slower fuse.

the apns log now names the mismatch rather than the reason code, since the
code alone sends people to look at the device: it prints the row's environment
and topic and says to check the SHIPPED artefact rather than the intermediate
build, which is exactly where this one hid.
2026-08-19 21:25:20 +00:00
pastilhasandClaude Opus 5 520fad2288 stopping a job that is already gone is a success, not an error
The chip could not be cleared by any means, and the toast could not be silenced
from the call site: useClient raises a GLOBAL onError before it throws, so
handling the failure where Stop is pressed was always going to be too late.

DELETE is idempotent. Asking for a job to stop when it is already gone has
achieved what was asked, so "no such task" and "already stopped" now answer 200
with `gone: true` instead of 400. No error, no toast, and the client clears the
chip on the flag.

A genuine refusal — a malformed id, a subagent with no fd to trace — is still
400.

The previous client-side check stays as a fallback, for a browser talking to a
server that has not been updated yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:47:28 +00:00
pastilhasandClaude Opus 5 177394fb76 Stop becomes Kill when the job ignores it
A background job that ignores SIGTERM could not be stopped from the UI at all.
Stop sends SIGTERM to every pid holding the job's output file, and the second
click sent the same signal again — task-restart.ts already said "that is what
the second click is for", and it was not. Reported after a job hung in another
chat.

The button now escalates instead of a second button appearing beside it. Press
Stop, and if the job is still running three seconds later the same control
becomes Kill in the destructive tone, and sends SIGKILL.

Escalating rather than always offering Kill, because a permanent Kill button is
a second destructive control competing with the first, and people reach for it
by default — losing the graceful shutdown that usually works. The delay is the
same argument: a clean exit takes well under a second, so offering Kill at once
teaches everyone to press it first.

`?force=1` on the existing DELETE rather than a second route: same verb on the
same thing, differing only in how hard it asks. The member path escalates too —
one `kill -KILL` run as them, since fuser and the kill must both be their own
identity.

Stated in the button's own title rather than hidden: SIGKILL gives the process
no chance to release a port or take its children with it. That is the right
trade for a hang and the reason it is not offered first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:02:18 +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 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>
2026-08-18 04:46:31 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 04:23:12 +00:00
pastilhasandClaude Opus 5 1b811dfa28 make plan mode reachable in chat
opusplan has been in the picker since 57bbcca9 and did nothing: it means
'Opus while planning, Sonnet at rest', and claude-manager hardcoded
permissionMode: 'bypassPermissions', so a session never entered plan mode
and opusplan rested forever on Sonnet.

verified against a real claude first (SDK 0.3.233), and one finding changed
the design: approving ExitPlanMode drops the harness into 'default', NOT back
into bypassPermissions, and the agent carries on inside the same turn. so the
restore is fired the moment approval is given rather than at turn end, and
canUseTool allows everything that is not ExitPlanMode - for a few hundred ms
it is the only thing between the agent and its tools, and a default-deny there
would jail an agent the platform deliberately does not jail.

canUseTool is passed always. under bypassPermissions the SDK shadows it
entirely (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED) so it costs a log line, and since
it is a query() option read once, passing it unconditionally is what makes the
mode toggleable on a live conversation instead of only at session start.

sticky per session on chat_session_models.plan_mode, beside the model because
they are one decision about what a turn costs. boolean rather than the
PermissionMode string: officer exposes two of the five, and a text column would
accept three the sidecar cannot handle.

plan approval is the only place the agent blocks on officer - a canUseTool
promise held in the sidecar while the plan round-trips to a browser. every path
that ends a session or a turn settles it as refused, plus a 20m timeout, because
a leaked entry is a hung turn rather than a stale value.

not auto-entered for opusplan: the CLI does not either. the toggle says so
instead - 'opusplan rests on Sonnet, turn on Plan for Opus'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:50:24 +00:00
pastilhas 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 (ed3806f9). The existing guard adopted
layouts LACKING the panel, so it had to flip to adopting layouts that HAVE it. defaultLayout.test.ts pins
both halves: the replacement must not contain the appType the guard tests for, or the effect fires on its
own output forever.

Deletes LiveSessions.tsx and PwdSelector.tsx — both replaced. Fixes the inherited bug in passing: the
"generating" Loader2 never had `animate-spin`, so the one indicator meaning "work is happening right now"
was a static icon.

View and expanded-node state go in localStorage, not the URL (a tab is a preference, and every session link
would carry it), not usePanelConfig (a NO-OP setter in a locked workspace, and /chat is locked), and not
useDashboardState (its PATCH only accepts layout-shaped objects). /chat/g/<path> still works and opens that
directory in the tree.

Untested against a running server: officer has not been restarted since these changes, and I am not
restarting it mid-conversation. `?all=1` and the model field are verified only by typecheck.
2026-08-17 17:30:44 +00:00
pastilhas 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.
2026-08-17 17:03:19 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 17:20:11 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 13:26:07 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 02:03:30 +00:00
pastilhas 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".
2026-08-15 16:03:22 +00:00
pastilhasandClaude Opus 5 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>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 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>
2026-08-14 15:58:36 +00:00
pastilhasandClaude Opus 5 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>
2026-08-12 23:18:48 +00:00
pastilhasandClaude Opus 5 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>
2026-08-12 01:54:19 +00:00
pastilhasandClaude Opus 5 532ad15ac1 drop the import the gate left behind
c59df4f removed the isSuperAdmin middleware and left its import, so the only two
matches in the file were the dead import and the comment explaining what used to
be there. Harmless and misleading: a grep for isSuperAdmin should not find chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:12:35 +00:00
pastilhasandClaude Opus 5 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>
2026-08-12 01:12:00 +00:00
pastilhasandClaude Opus 5 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 6aeb304 be deleted.

chat.ts resolves per request and throws FORBIDDEN rather than falling back, same
posture as resolveTurnIdentity. agent-runner passes the owner's home explicitly
rather than inheriting it, since that path really is owner-only.

Made at 01:00 after saying it should not be. Three things I am least sure of are
listed in COMMS 29 rather than left for the reviewer to find: chat routes now
have a failure mode they did not have, resolveBaseCwd's exported parameter
changed meaning rather than shape, and the bare-email rewrite in chat.ts was
mechanical with hand repair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:48:09 +00:00
pastilhasandClaude Opus 5 d59adbf1f2 scope the six sessionKey commands to their caller
The control surface half of 7cb402b, and the code-side blocker on the gates.
kill, interrupt, clear-session, is-generating, find-session and list all took a
bare sessionKey, so any caller who could reach them could act on whichever
session happened to match — and list returned every session in the sidecar,
which host rightly called a disclosure on its own, before anyone kills anything.

All six now carry userId, resolved from the authenticated request and never
taken from the client, and every handler enforces it through one ownedSession
helper. list is filtered rather than labelled. find-session is scoped because it
is the reattach hinge: a browser holding a transcript uuid it should not have
would otherwise be handed the session key that drives it.

"Not yours" and "does not exist" answer identically everywhere, which is the
same choice getClaudeSession made: every caller treats them the same, and a
distinct answer for the second confirms to a guesser that a session exists under
a key they do not own.

One behaviour change beyond the scoping. endTurnIfAgentIsGone sweeps sessions on
a sidecar restart, and a session with no recorded userId now has no safe id to
ask as — asking as the owner would answer a member's orphaned session with the
owner's authority. It is skipped, so it stays marked generating until the next
reconnect corrects it, which is what happened before that loop existed.

This removes the code-side reason the gates cannot move. It does not make them
movable: no member has signed in, no member turn has run, spawnClaudeCodeProcess
has still never been called, and lifting them was never mine to decide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:17:53 +00:00
pastilhas e4acf19a35 Merge remote-tracking branch 'gitea/master' into sidecar-app-store 2026-08-11 19:10:52 +00:00
pastilhasandClaude Opus 5 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>
2026-08-11 19:03:00 +00:00
pastilhasandClaude Opus 5 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>
2026-08-11 02:41:24 +01:00
pastilhasandClaude Opus 5 ecc10ae6af name a live opencode row from the prompt until opencode names it
Same shape as the claude side, which has never shown a live row without a name.

OpenCode titles a session from the conversation and does it well, but asynchronously — so
for the whole time a turn is RUNNING, which is exactly what /chat/live shows, the session is
still called "New session - <ISO>". Its own title wins the moment it exists; until then the
row falls back to the prompt that started the session.

Kept per sessionKey, first turn only, so it stays the name of the conversation rather than
following whatever was asked most recently. Dropped with the session id it sits beside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:00:13 +01:00
pastilhasandClaude Opus 5 90e0ca8ab4 stop showing opencode placeholder titles as if they were names
OpenCode titles a session from the conversation, but asynchronously — a finished turn of
ours ended up called "Single color in oc-red2.png", better than anything we would generate.
Until then the session is literally named "New session - 2026-08-10T15:44:17.178Z".

That window is exactly when a session is most visible: /chat/live shows turns that are
RUNNING, so the placeholder is what the panel catches, and a live row was being labelled
with a timestamp string.

Recognise it and treat it as untitled, so the good name arrives on its own. Passing --title
on the run was the other option and is worse: it fixes the transient case by permanently
replacing opencode own title with a truncated prompt, degrading it where it lasts longest.

A pattern match rather than startsWith, because a genuine title is allowed to begin with
those words. Two defects the tests caught while writing them: a whitespace-only title was
not treated as unnamed, and the mapping let undefined through where a string was required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:56:07 +01:00
pastilhasandClaude Opus 5 9118a9f76c name the live opencode rows
They were permanently unnamed, and the two halves needed to name them already existed on
officer: the sidecar reports its own sessionKey because that is all it has, while the ses_ id
arrives separately over opencode:session and is recorded in opencode/state.ts. Nothing joined
them. /chat/live joins them now, so no protocol or sidecar change — widening
LiveOpenCodeSession would have meant sending the sidecar a fact it told officer in the first
place.

One list call names every row rather than one transcript load each, and it is skipped when
nothing is running or no id has been reported, so an idle Live panel never touches the serve.

Verified against a real turn, which also showed the design working as intended: the first
poll has no id yet and shows nothing, the next shows title and cwd. That window is real and
short, and showing nothing beats showing a key the user has never seen.

Worth knowing: opencode titles its own sessions "New session - <ISO timestamp>", so the row
is located but not meaningfully named. That is genuinely its title, not a bug here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:42:15 +01:00
pastilhasandClaude Opus 5 42190f007f say what the live opencode rows actually do
Two comments describing behaviour the code does not have.

LiveOpenCodeSession had been inserted between LiveClaudeSession and its docblock, so a
comment about isGenerating, pendingTasks and the idle GC read as documentation for the
OpenCode type — where it is contradicted by the correct comment directly beneath it.
Moved below, and it now states that it carries no ses_ id.

That absence is the point: /chat/live claimed title and cwd come from the session store
"so a turn whose id has not been reported yet shows unnamed". Nothing is looked up, and
there is no id here to look one up with. They are null permanently, not until-known.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:50:46 +01:00
pastilhasandClaude Opus 5 e8bd946272 show running opencode turns in the live panel
The Live panel asked `claude:list` and nothing else, so a running OpenCode turn was invisible — the panel
claimed to show what the agent is doing and silently omitted half of it.

Adds `opencode:list` / `opencode:sessions` and merges both harnesses in `/chat/live`, asked in parallel,
each failing toward empty so one sidecar being down contributes nothing rather than breaking the panel.

The OpenCode row is deliberately thinner than the Claude one rather than faked into parity:

  isGenerating  always true — a subprocess exists only while it generates, so there is no "merely open"
  pendingTasks  always 0    — `opencode run` has no background-task concept; reporting a number would
                              suggest a capability that does not exist
  title / cwd   null        — the session store is keyed on the `ses_…` id the runner reports, not on
                              our sessionKey, so an unreported turn shows unnamed rather than guessed

This is the incremental option from docs/opencode-serve-path.md — enumeration without moving turns onto
the serve, so it buys the Live panel with no warm sessions, no SSE loop and no lifetime questions.

NOT verified end to end: no OpenCode turn was running to enumerate, so the verb is wired and typechecked
but has never returned a non-empty list. See COMMS/BLOCKERS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 03:00:43 +00:00
pastilhasandClaude Opus 5 5a7591124a resolve live sessions by claude's id, not the agent's session key
Every live row read 'Starting…' and linked nowhere, because the title lookup searched for a transcript
named after the session KEY. It isn't. The key is officer's handle for a conversation; the transcript is
named after Claude's own session id, and the mapping between them exists only inside the agent
(setClaudeSession/getClaudeSession). I assumed the two were the same and never checked — confirmed wrong
by looking for the ids from the officer log under ~/.claude/projects and finding nothing.

claude:list now reports claudeSessionId beside the key, the route resolves titles by that, and rows link
to it. Null means the first turn has not reported one yet, which is a genuinely unwritten conversation
and stays unlinked.

Needs the agent sidecar restarted to take effect — the new field comes from there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:23:14 +01:00
pastilhasandClaude Opus 5 27bbbaee47 name the live sessions instead of showing their ids
The titles were looked up client-side against the sessions query already in cache, which only covers the
group being browsed — so anything running in another directory rendered as a truncated uuid, which is
most of them.

Resolved server-side now. The agent reports keys and nothing else, so liveSessionTitle finds the
transcript by scanning the project slugs, takes the cwd off its own first entry, and hands that to
claudeSessionContext — the same path the list uses, so the two agree on naming, /clear chains merged
included, rather than offering a second opinion.

A null title means no transcript has been written yet. That row says 'Starting…' and is deliberately not
a link: pointing at a session you cannot open yet is worse than plainly not being a link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:18:54 +01:00
pastilhasandClaude Opus 5 a75bf7a283 ask the agent what it is still running
Officer's session records are in memory and die with `pm2 restart officer`, while the agent is a PM2
peer and keeps generating. `adoptOrphanedSession` rebuilds a binding — but only when a browser
reconnects to a session *by id*, which you can only do if you already knew the id. So a session that
survived a restart was invisible, and nothing could answer "what is running right now".

`claude:list` returns each live session with `isGenerating` and `pendingTasks` — the same two fields the
agent's own `armIdle` consults before collecting a session, so a caller can tell "busy" from "merely
open" the way it does. Surfaced as `GET /chat/live`, which sits beside `/chat/sessions`: those are
transcripts on disk, these are the ones with a process behind them.

`getActiveSessionKeys` is replaced rather than joined. It returned bare keys, could not distinguish a
session mid-turn from one merely open, and had never been called by anything.

`listLiveClaudeSessions` fails toward EMPTY, where `isClaudeGenerating` beside it fails toward alive.
The asymmetry is deliberate: not knowing there means leaving a spinner up, and not knowing here would
mean inventing sessions.

Step 2 of docs/chat-session-lifetime.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:35:11 +01:00
pastilhasandClaude Opus 5 8773da5953 feat(chat): agent panels — named Claude panels that can hand work to each other
Committing work that was left uncommitted in the shared tree. I did not write it;
I reviewed it in full, verified it against the running system, and am landing it at
the owner's explicit request because no one currently owns it.

This REPAIRS master. `useAgentPanel.ts` shipped in dbe585f and calls
`/chat/agent-panels`, but `registerAgentPanelRoutes` existed only in the working
tree — so on master as pushed, every one of those calls 404s. The feature has been
half-landed since that commit.

What it is. A panel on a dashboard can be given a name ("frontend", "code-reviewer").
Naming it mints two things: a `sessionKey`, which is the panel's permanent continuity
(it keys the sidecar's on-disk resume map and `chat_session_events`, so the same panel
reopens the same Claude session), and a `handoffToken`, a bearer credential scoped to
exactly one verb. The agent in that panel is then addressable by name, and can pass
work to a peer on the same dashboard over `/api/agent-handoff`.

Three doors, deliberately separate:
  - `/chat/agent-panels` (browser, session-authed) — name / list / rename / forget.
    Mounted on the chat router rather than given its own prefix: these routes create
    and name Claude sessions, which is authority `chat` already grants. A second
    top-level mount would have meant a second capability entry claiming the same
    thing under a different name.
  - `/api/agent-handoff` (agent, token-authed) — peers and send. Unprotected by the
    session middleware and exempted in `capabilities/totality.ts` with its reasoning
    written down, because the caller is a subprocess with a token, not a browser with
    a cookie.
  - The transcript stays where transcripts live. DELETE forgets the address and the
    panel's claim on the session; it does not touch ~/.claude/projects.

Security, as verified rather than assumed:
  - The sender is derived from the token, never from the request body — there is no
    `from` field on the wire, so it cannot be forged.
  - Every lookup is scoped to the token's `userId` AND `dashboardId`, so an agent can
    only see and reach peers on its own dashboard.
  - `toAgentPanelView` strips `handoffToken` and `userId`, and it is the only shape
    the browser routes return. Confirmed by reading every return path.
  - Live-tested: a real token on `GET /api/agent-handoff/peers` returns 200 with
    correctly scoped peers; a bogus one returns 401.

Two judgement calls in the code worth knowing about, both already commented at their
site: the introduction turn inlines the handoff token into a runnable curl (a
single-owner MVP trade), and `agent_panels` carries no FK to `dashboards.id` because
that primary key is mid-rework to a composite.

Schema uses `uniqueIndex` throughout, never `unique().on(...)` — the rule that exists
because drizzle-kit mis-diffs named composite unique constraints and re-creates them,
which is what wiped seven tables on 2026-08-03.

NO `bun db:push` IS NEEDED. `agent_panels` is already live in Postgres with 6 rows;
the schema file is catching up to a database that already has it.

Verified: `bunx tsgo` clean, `bun test` 538 pass / 0 fail across 35 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:47:48 +00:00
pastilhasandClaude Opus 5 47894702ac merge a /clear chain into one conversation
The list collapses each chain to its newest link — the only one that can be
resumed — carrying the root's title and start time, the summed message count
and a part badge. The detail splices the chain's transcripts oldest-first with
a divider between parts, server-side, so the client's index-window pagination
needed no change.

The divider says "context cleared — nothing above this is in memory", because
the whole risk of merging is that the history reads as continuous when the
agent's context is not. Delete cascades the chain and the confirm says how many.

Supersedes the "continues X" line from the previous commit: there is nowhere to
link to once the parent is scrolled up above you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:44:33 +00:00
pastilhasandClaude Opus 5 942cc2b61d link cleared sessions back to the conversation they continue
/clear starts a new claude session and the list showed it as an unrelated
conversation. claude records no parent link anywhere — not in compactMetadata,
logicalParentUuid, summary.leafUuid, the per-session slug, or the live process
registry — so infer it: a cleared transcript opens with /clear, and /clear
happens inside one process, so the parent is the conversation in the same group
that was writing to disk at the instant this one began (4ms apart, measured).

matching is on per-minute activity rather than updatedAt, because resuming a
parent moves its end time past its child's birth and lost the link entirely for
two of the three cleared sessions here. the window is symmetric because clearing
makes claude summarise the conversation it is ending, so the parent's final
record can land after the child's first. ambiguity fails closed — the wrong
parent also renames the conversation.

read-only: nothing is written back to claude's store, and an explicit title
always wins.

also: cleared sessions were titled "<command-name>/clear</command-name>" because
claude does not set isMeta on slash commands; and the chat header was hardcoded
to undefined, so it read "New chat" above every conversation you opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:22:28 +00:00
pastilhasandClaude Opus 5 82aa39a05f chat: pin background tasks above the input and let you look inside them
Background tasks already had a row in the transcript, but a row scrolls away — and a
task started ten minutes ago is precisely the one you want to keep an eye on. The same
`role: 'task'` rows now also drive a tray docked above the chat input: a chip per task,
running ones pulsing, finished ones dismissable.

Clicking a chip opens what the task is actually doing right now. Nothing about that
crosses the wire between `task:started` and the notification, so it is read from the
file Claude Code streams the task into:

  $TMPDIR/claude-<uid>/<project-slug>/<session-uuid>/tasks/<task-id>.output

For a backgrounded shell that file IS the log; for an agent it is a symlink to the
subagent's own transcript, which is ordinary session JSONL and so parses with the
reader we already had. Both kinds are therefore reachable from one directory.

Resolution is by task id alone, deliberately: the client learns a task id from
`task:started` and nothing else — officer's per-connection session key is not Claude's
session uuid, and the uuid only arrives with the turn result, long after the tray needs
to show the task. A task that has not written anything yet answers 200 `{kind:'pending'}`
rather than 404, because that is the ordinary first second of a task's life.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:40:26 +00:00
pastilhasandClaude Opus 4.8 b27dd7512b load chat transcripts as a tail window and lazy-load older on scroll-up
extremely long transcripts made bottom-anchoring the virtualized list unreliable
(thousands of unmeasured variable-height items = a huge estimate the scroll never
lands on). now GET /chat/sessions/:id takes limit+before and returns a windowed
slice plus total+offset. the chat opens on the last 20 messages, anchors to the
bottom instantly, and scrolling near the top pages in the next older window,
prepending it and pinning the previously-top message so the view stays put.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 03:49:13 +00:00
pastilhasandClaude Opus 4.8 3682269936 resolve /chat/<id> by scanning project groups and make session rows real links
deep-linking or refreshing /chat/<id> only has the id, so add loadClaudeSessionById
to scan every project group for the transcript and return its real cwd. the session
list page resolves on load from that, setting the cwd picker and resuming. session
rows are now <Link to=/chat/<id>> so clicking updates the url and flows through the
same resolve path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 03:35:47 +00:00
pastilhasandClaude Opus 4.8 0c3f270419 chat: rename claude_sessions → general_chat_sessions; drop dead chat_sessions
The default /chat working directory is used by both the Claude and OpenCode harnesses
now, so its Claude-specific name was misleading.

- Rename the dir + accessors: getClaudeSessionsCwd → getGeneralChatSessionsCwd,
  ensureClaudeSessionsCwd → ensureGeneralChatSessionsCwd, path segment claude_sessions
  → general_chat_sessions (data-path on disk + code + UI labels/comments). No history
  migration — the old Claude transcript slug is orphaned (intentionally).

- Remove the vestigial chat_sessions dir (leftover from the retired session store):
  it only ever held empty claude/archived/ dirs, recreated by a signin hook. Drop that
  hook (+ its dead imports) and the 4 unused data-path accessors (getUserSessionsDir,
  getClaudeDir, getSessionDir, getArchivedSessionDir), and delete the dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:50:48 +00:00
pastilhasandClaude Opus 4.8 7cd2be9fb9 chat: tag OpenCode sessions with officer metadata (cwd) + filter lists by it
OpenCode has no per-session directory (every session runs in the fixed server's cwd),
so sessions from every context (/chat pwd, email account, project) all landed in one
list. Now each session is tagged on creation with its logical cwd via the free-form
session `metadata`: { officer: { cwd } } — API-settable, round-trips on list+detail,
never touched by opencode core (confirmed by source dive + live test).

- client.createSession(metadata?) sends `metadata`; adds OfficerSessionMeta + officerMeta() helper.
- send-opencode tags new sessions with { officer: { cwd } } (the resolved chat cwd).
- websocket: pass the full resolved cwd for every context (not just non-/chat).
- listOpenCodeSessions(cwd?) filters by metadata.officer.cwd; chat.ts passes the request cwd.

Verified live: sessions tagged with distinct cwds list only under their own cwd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:50:48 +00:00
pastilhasandClaude Opus 4.8 7b16f3bc4c chat: point OpenCode at a fixed pm2-managed server (fixes empty model picker)
The per-cwd `opencode serve` spawning is replaced by a single fixed server
(http://127.0.0.1:4096, OPENCODE_SERVER_URL) managed by pm2 — added as
`officer-opencode` in ecosystem.config.cjs (cwd = home).

Root-cause fix for the empty model selector: list-models shelled out to
`opencode models`, which failed at runtime on the deployed server (the picker got
only Claude tiers). It now reads the fixed server's GET /config/providers over HTTP —
11ms and reliable — so the curated OpenCode models (Big Pickle, Claude Haiku) show up.

- server-manager.ts — drops spawning; exposes OPENCODE_SERVER_URL + a health check.
- client.ts — createSession no longer binds a directory (sessions live in the one
  server's project).
- send-opencode.ts / opencode-sessions.ts / chat.ts — use the fixed server; drop the
  cwd/home plumbing. Session list/load/delete/rename now hit :4096.

Verified end-to-end against the live server: model list, streaming turn, session
list, and transcript load all work with no spawning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:12:53 +00:00
pastilhasandClaude Opus 4.8 059539a64a chat: unified session history across Claude + OpenCode (Phase 3)
/chat's session list, transcript load, delete, and rename now span both harnesses.

- opencode-sessions.ts — REST-backed reader (OpenCode's SQLite via its HTTP API, never
  the DB): listOpenCodeSessions / loadOpenCodeSession / delete / rename, returning the
  same shapes as the Claude reader, tagged harness:'opencode'. A serve is directory-
  scoped, so listing a cwd = asking the serve rooted there. Transcript rebuild maps
  user/assistant/tool parts and drops reasoning (parity with the delta filter).
- client.ts — adds listSessions/getMessages/deleteSession/renameSession over /session/*.
- chat.ts — /sessions merges both (newest first); /sessions/:id, DELETE, and
  /title route by id shape (ses_ = OpenCode). ClaudeSessionSummary gains an optional
  `harness` tag.
- send-opencode.ts — resuming from history: when the sessionKey is itself a ses_ id,
  reuse that OpenCode session instead of creating a new one.
- SessionList.tsx — shows an "OpenCode" badge for OpenCode sessions.

Verified end-to-end against a live serve: list (5 sessions, tagged), load (transcript
rebuilt, reasoning filtered), and rename all work. Phases 1-3 complete; needs a restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:56:20 +00:00
pastilhasandClaude Opus 4.8 025c5dd38c chat: surface OpenCode models in the picker (Phase 2)
list-models.ts now merges the OpenCode catalog (from `opencode models`, cached;
ids are providerID/modelID) with the static Claude tiers, so /chat/models returns
both. invalidateModelCache clears the OpenCode cache for real now.

Adds the 'opencode' → 'OpenCode Zen' provider label in the /models response and the
ModelSelector's PROVIDER_DISPLAY. The existing useModels visibility gates already
pass non-claude-code providers through, so no gate changes are needed — Super Admin
sees all models. Selecting any non-claude-code model routes the turn to the OpenCode
harness (Phase 1).

Default model stays 'claude-code'. 58 OpenCode Zen models currently list; curating to
a flagship subset is an easy follow-up if the full catalog is unwieldy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:32:12 +00:00
pastilhasandClaude Opus 4.8 49df1c0b0c chat: rename the pi chat transport + model list to chat (Stage 4b/1)
Pure rename, no behavior change. Moves the misnamed "pi" chat harness into the
chat namespace:

- api/pi/{websocket,session-manager,types,logger,list-models} → api/chat/
- merge api/pi/rest.ts into api/chat/chat.ts (/pi/models → /chat/models,
  /pi/stt → /chat/stt); drop the piRestRouter mount
- PiEvent → ChatEvent, piWebsocket → chatWebsocket, listPiModels → listChatModels
- WS route /api/pi/chat/ws → /api/chat/ws, provider tag 'pi' → 'chat'
- frontend: useChat/useAudioRecording URLs, usePiModels→useModels /
  useVisiblePiModels→useVisibleModels / useEnabledPiModels→useEnabledModels,
  'PI_MODELS' query key → 'CHAT_MODELS', attachments provider 'pi-mono' → 'chat'

The /pi-mono provider/harness settings router is renamed separately (next commit).
Note: the WS route change requires the mobile app to point at /api/chat/ws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:47:40 +00:00
pastilhasandClaude Opus 4.8 04bde70ed3 chat: pwd selector — switch the /chat working directory
The Sessions panel header gets a pwd selector: the default claude_sessions dir,
plus every directory that already has Claude sessions (auto-discovered by reading
the real cwd back from ~/.claude/projects), plus free-text entry. Switching pwd
refetches the list for that cwd's Claude project group and runs New Chat / resume
in it. Backend: GET /chat/pwds + a ?cwd= param on the session ops; the WS handler
honors a chosen cwd for /chat (default claude_sessions). Browse-modal picker next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:41:49 +00:00
pastilhasandClaude Opus 4.8 b3b8a59863 chat: rename sessions in Claude's store + delete confirm
Rename appends a {"type":"summary",...} entry to the session's JSONL transcript
(Claude's own format, so the title lives in .claude); the reader takes the last
summary as the title, without a timestamp so it doesn't reorder the list.
PATCH /chat/sessions/:id/title backs it. The list gets inline rename (pencil ->
edit in place) and a two-step delete confirm so a stray click can't nuke a
transcript. Rename verified against a synthetic store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:01:16 +00:00