Commit Graph
347 Commits
Author SHA1 Message Date
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 8b409e8af8 finish phase 1: correct the stale comments, and put the ndjson mapping under test
Path names: the serve's cwd is DATA_PATH/opencode_server, not opencode-sidecar. Two comments said
otherwise and would send the next reader to a directory that does not exist.

Version pin: the comment claimed "verified live against 1.17.9" as though that were a property of the
code. It is a property of whichever binary is installed, and this project already runs two — 1.17.9 here,
1.18.11 on the other machine. Says so now, and points at the test as the thing that actually enforces it.

Tests, the first on the OpenCode path. `runner.ts`'s NDJSON → ChatEvent mapping was described as pure and
untested; it was untested but not pure — it lived inside `handleLine` as a closure over `emit`, the
accumulated cost and a reported-session flag, so it could not be called without spawning a binary.

Extracted as `mapRunLine`, genuinely pure: line in, {sessionId, events, costDelta} out. The two concerns
that span lines stay with the caller, because they are not properties of a line — emitting the session id
exactly once, and accumulating cost across steps. Behaviour is unchanged.

11 tests over what the mapping forwards, what it drops and what it must not turn into NaN. The last one
matters: a missing `cost` on a step_finish would otherwise propagate NaN into the turn total.

Phase 1 is complete: dead code deleted (previous commit), comments corrected, tests added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:58:11 +00:00
pastilhasandClaude Opus 5 d7b223127a delete the dead serve-turn client, and answer phase 2's blocking question first
Phase 1 item 7, done in the order the parity doc asks: read the dead design into a note, then delete it.
`docs/opencode-serve-path.md` records what `event-mapper.ts` and the SSE half of `client.ts` did, and
what a rebuild would want back from them — the delta model and tool-state transitions, which are exactly
parity Phase 3's token streaming rather than new work.

Deleted: `event-mapper.ts` entirely, and `subscribe`/the shared `GET /event` SSE loop, `createSession`,
`postMessage`, `abort` from the client, plus `isServerHealthy` from server-manager. All had no callers.
`client.ts` goes 200-odd lines to 99. What stays is the REST reads the chat list and transcript use:
listSessions, getSession, getMessages, deleteSession, renameSession.

While in there, Phase 2's blocking question turned out to be cheap to settle, so it is answered rather
than left open. The review asked whether the serve can take a per-request directory, since without one a
serve-based turn path would reintroduce the single-directory coupling that shelved this work:

  POST /session?directory=/tmp/oc-phase2-probe  ->  directory: "/tmp/oc-phase2-probe"   honoured
  POST /session  with directory in the BODY     ->  directory: "<serve cwd>"            ignored

It is a query parameter on every /session* route. So the coupling is gone on both architectures and the
blocker is cleared. The note does NOT start the migration: which of the three options to take is a
product call, and it lays them out rather than presuming one.

The first probe put `directory` in the body and appeared to prove the opposite. Recorded in the note,
because it is the obvious way to test this and it gives a confident wrong answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:55:18 +00:00
pastilhasandClaude Opus 5 cfbf58cdba delete the AGENTS.md injection; --dir was always the real anchor
The sidecar seeded an AGENTS.md into the serve's project root telling the agent to read its working
directory from the "Working directory for this session" line in its system prompt. The run path sends no
system prompt, so there was no such line and the instruction had been inert since turns moved off the
serve to `opencode run --dir`.

What it was standing in for, `--dir` does properly — tested rather than assumed
(docs/opencode-phase0-review.md): `--dir` anchors the agent's own file operations, not just the process
cwd, and the anchor survives a multi-step turn with a write in the middle. Nothing replaces it.

The generated file is removed from disk too, not only from the code that wrote it; leaving it would have
kept feeding standing instructions to every session while looking, in the source, as though it were gone.

Also corrects the claim that all OpenCode sessions live in one server's project. True when turns
inherited the serve's directory, false now: one serve lists 7 sessions across several directories, which
is why the cwd filter has to read `directory` rather than assume a single one.

docs/opencode-phase0-review.md, item 2 — Phase 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:51:20 +00:00
pastilhasandClaude Opus 5 013e6296f1 finish phase 0 of the opencode parity list
Three defects and one honest removal. Each was reproduced before being changed, as the doc asks.

B4 — images were offered and silently discarded. Every OpenCode model advertised `images: true`, the
composer gates on that flag, the bubble rendered the attachment, and `handleOpenCodeChat`'s message type
has no `images` field, so it never left officer. Flipped to false: 61 OpenCode models now decline, the
three Claude ones still accept. Plumbing them through OpenCodeRunParams stays Phase 4; advertising a
capability that does not exist is the part worth fixing today.

B5 — every OpenCode turn overwrote the previous turn's subscription handle without detaching it, so the
old session-scoped listener stayed attached and delivery doubled, tripled, and so on for any termination
that is not result/error/stopped. Deliberately NOT the Claude guard: Claude keeps one persistent session
and skips re-subscribing, while OpenCode spawns a fresh `opencode run` per turn, so a new subscription
each time is correct — detaching the old one is what was missing.

B6 — the sessionKey → `ses_…` map had no writer of deletions, so it grew for the process lifetime and a
reused key resumed a stale OpenCode session. Cleared in `deleteSession` only, never in `releaseSession`:
releasing means "let go, leave it running", and a returning browser must find the same `ses_…` again.

Phase 0 item 1 — the thinking toggle is removed rather than fixed. `thinking` is accepted on the wire
and forwarded by neither channel, so the control changed its own label and nothing else. Out of scope
for both harnesses by decision. The inert plumbing beneath it is left for a follow-up that touches the
socket contract; the props stay accepted-and-unread so no call site had to change.

Phase 0 is complete: B1, B2, B3 landed earlier; B4, B5, B6 and the selector here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:31:27 +00:00
pastilhasandClaude Opus 5 22bcd7d4b9 list opencode sessions at all, and give a resumed one its directory back
Two defects, one cause: the session type declared two fields opencode 1.17.9 does not return.

`GET /session` returns `directory` at the top level. There is no `location` object and no `metadata`.
Re-verified by reading the live server rather than the type.

So `metadata.officer.cwd` was compared against `undefined` for every session and the list filter matched
nothing — and since `cwdOf` substitutes a default when no `?cwd=` is given, the `!cwd` escape never fired
either. There was no configuration in which an OpenCode session appeared in /chat. Confirmed against the
running server: 7 sessions present, 0 returned, and the `OpenCode` badge in SessionList was unreachable
code. Now 1 of 7 is listed under the default chat dir, the other 6 correctly filtered to their own
directories.

And `location?.directory ?? ''` was likewise always '', so resuming a session reported no cwd and
relocated the conversation to the default chat dir — which matters because OpenCode rebuilds its
working-directory system prompt every turn. Detail now reads the session's own record via a new
`getSession`, alongside the transcript.

`officerMeta` and the `metadata` tag are gone rather than fixed: the only writer of that tag
(`client.createSession`) has no callers, because the sidecar creates sessions with `opencode run --dir`.
Tagging would have been a second source of truth for something `directory` already answers.

docs/opencode-parity.md B1 and B3. Its suggested fix — derive from `location.directory` — was written
against a field that does not exist; the doc asked for the version to be checked first, and this is why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:59:19 +00:00
pastilhasandClaude Opus 5 da5cd8e918 stop losing the answer when immich rejects mid-upload
An upload over a few MB came back as a 400 with an empty body, no message anywhere, and nothing logged
by Immich, the sidecar or officer. It was a race, not a size limit. Immich judges an asset from its
first few KB and rejects immediately, then closes; both our hops were still writing the body; Node
treats the leftover bytes as a protocol violation and replaces the application's answer with a bodyless
`400 Bad Request` + `Connection: close`. The real message never reached the wire.

Measured before the change: streamed lost the message 1/4 at 8 MB and 4/4 at 32 MB — probability rising
with size, which is why small photos usually worked and a phone's video never did.

Both hops needed it. Fixing only the sidecar took 32 MB from 4/4 failing to 2/4, because the platform
proxy was losing it one hop up.

Bounded at 512 MB, above which the body streams exactly as before. That ceiling is not a refusal and is
deliberately not a 413: a file Immich ACCEPTS is read to the end and never races, so a 4 GB video is
unaffected. All that is given up above the cap is the error message on a file that was going to be
rejected anyway. A first attempt refused over-cap uploads outright and would have broken the working
4 GB case to improve diagnosis of the doomed one.

`bufferRequestBody` is opt-in and off by default: the vault and wallet proxies must keep streaming so a
passphrase or macaroon never lands in the platform's heap.

Also adds the proxy error logging that made this findable at all — status and two byte counts from
headers, never the bodies. `responseBytes: "unknown"` is what exposed the stripped response.

Verified live at 32/256 MB (buffered) and 640 MB (streamed, passes through).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:22:56 +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 be266da9e2 let an idle browser go without taking the agent with it
Officer's hour-long idle timer was doing two unrelated jobs: collecting its own in-memory binding, which
is its business, and terminating the agent, which is the sidecar's. It could not do the first without
the second, because `unsub` was a closure reachable only through `kill`.

So a browser that went away killed a live agent an hour later — including one the sidecar had
deliberately protected. The sidecar already refuses to collect a session that is mid-turn or holding
background tasks: `task:started` disarms its idle GC, and `armIdle` re-checks and re-arms rather than
firing once. Officer had no view of any of that. A laptop running out of battery overnight took a
`run_in_background` job with it for no reason.

`detach` now sits beside `kill` on both streaming handles, and `_sidecarUnsub` — declared and called for
a long time, never once assigned — is populated at all three sites. `releaseSession` unsubscribes and
forgets the record without killing; the idle timer points at it. `deleteSession` is unchanged, so an
explicit disconnect still ends the session.

The third assignment site was not in the plan: `adoptOrphanedSession` sets `_claudeKill` but nothing
else, so an adopted session that later idled out would have dropped its record while the listener stayed
subscribed — a leak of one per adopt-then-leave.

No double subscription: releasing unsubscribes first, so a returning browser either adopts with a fresh
listener or starts a first turn with none behind it.

Step 1 of docs/chat-session-lifetime.md. Step 2 (a list verb, so running sessions can be found after a
restart) is still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:10 +01:00
pastilhasandClaude Opus 5 ae275ee607 resolve api keys at the websocket doors too
there were four doors, not two. the ws upgrade in server.tsx and the
vault notifications socket each verified the jwt themselves, so a key
that worked against /api would have 401'd on cliamp — signed in and can
play audio would have been two different questions for the music app.

both now call resolveAuthToken. verified: owner key upgrades cliamp
(101), bogus key 401, member key 403 on terminal exactly as their jwt
is.

reset-password and verify-token deliberately keep verify() — they read
a purpose-scoped reset token and a key must not be spendable as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:40:04 +00:00
pastilhasandClaude Opus 5 00997f5ff1 per-user api keys, resolved at both identity doors
a user can mint a long-lived key for an app or a device instead of
carrying a 30-day session, so multiple logins on the mobile apps are
per-device revocable rather than one shared token.

identity was being decided independently in userMiddleware and
originScopeMiddleware, each verifying the token itself. teaching only
one of them a new credential format is how those two stop agreeing, so
both now call resolveAuthToken and neither knows what a bearer string
is. verified: a member's key returns the same status as their jwt on
every route tried, 403s included.

a key carries its holder's full authority — not an escalation, it
equals what the password could already do. scoping wants a scopes
column, not a change here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:10:14 +00:00
pastilhasandClaude Opus 5 d56be0301d retire the single-user claim from the docs it outlived
CLAUDE.md asserted "single-user is a hard invariant, not a stage" while
users held six rows and role_capabilities held grants. Every doc that
repeated it is corrected here, in prose and in the code comments that
carried the same claim.

The accurate statement is narrower: one owner who bypasses every check,
other accounts holding only what their role is granted, and a set of
capabilities — terminal, chat, files, tasks, items, desktop, browser — that
are structurally ungrantable because they execute as the owner's OS user.

TODO.md gains a Multi-user section for what the read turned up: no way to
create a second account, dashboards.id colliding across users, authorize.ts
untested, pty/vault/opencode taking no identity, Radicale still owner_only.

claude-sidecar-isolation.md's open question is answered rather than left
open — the per-email spawn model is dead weight, because chat is an
execution capability and no second account can ever reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:58:44 +00:00
pastilhasandClaude Opus 5 ac64a7362b close three cross-account holes found reading the multi-user path
reset-password accepted any valid signed jwt as a reset token, including a
30-day session token — its sibling verify-token.ts already gated on
purpose === 'reset-password' and this handler did not. forgot-password mints
that claim, so the gate costs the legitimate flow nothing.

notify's DELETE /_officer/devices/:token deleted by token with no user
predicate: a token is the address of a device, not a secret, so any account
holding the notify capability could deregister another's device.
deletePushDevice now takes an optional userId — the route passes it, the
APNs/FCM dead-token paths deliberately do not.

POST /_officer/notify let a request body's userId override the
proxy-injected X-Officer-User. The header now wins where present, which is
what separates a signed-in browser from a loopback producer that has no
session to speak from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:58:36 +00: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 66b9cf634c keep background task notifications out of the transcript
The harness delivers a finished background task to the agent by writing it as the user's next
message, so Claude's file holds a raw <task-notification> envelope as a user turn. Live it never
shows, because the same event travels separately as task:notification — it appeared only when a
refresh rebuilt the conversation from the file, as a bubble on the owner's side he never typed.

Same defect as INTERRUPTION_MARKERS and the same fix. Anchored to the start of the message so
quoting one inside a real message stays yours. Also skipped when picking a session's title, where
it is no more a title than a slash command is.

Verified against a live transcript: 38 user bubbles before, 32 after, the 6 removed being exactly
the notifications.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:40:29 +00:00
pastilhasandClaude Opus 5 bffae5ef61 log how many background tasks an attach recovered
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:25:48 +00:00
pastilhasandClaude Opus 5 d5a3bae367 recover still-running background tasks on reattach
A task row is officer's own invention, synthesised from the harness's system.task_started, and
nothing corresponding to it is ever written to Claude's transcript. So rebuildTranscript can only
produce user/tool/assistant rows, and sync:live deliberately carries no messages — which left the
background-task tray empty after a mid-task refresh even though the work was still running.

Fold the durable log on attach into started-minus-notified and hand that back on sync:live. The
same read now supplies the cursor, so this costs one query rather than two. Finished tasks are
excluded: replaying those would resurrect rows already seen to resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:17:05 +00:00
pastilhasandClaude Opus 5 6b4339052a reattach a refreshed browser to a running turn
Refreshing mid-turn appeared to kill the agent's output. It never did: the
session survives a dropped socket, the agent keeps generating into it and keeps
committing durable events, and `close` only detaches the socket and arms an
hour-long idle timer. What broke was purely delivery — and the reconnect path
that would have fixed it could not fire, because the browser came back having
forgotten officer's session key. It lived in page state. The only id left was
Claude's transcript uuid in the URL, and nothing accepted that.

So accept it. `attach` carries the uuid, and the agent's on-disk session map —
the single record relating the two — turns it back into the key everything else
is written in terms of. The uuid now also goes out at `system.init` rather than
only at `result`, which is what makes the first turn recoverable at all: until
now a chat had no address until it had finished, and a long first turn is
exactly the one worth reconnecting to.

`sync:live` deliberately carries no messages. The harness writes its transcript
as it goes, so the HTTP load on landing already supplies the past; sending the
server's record of the same messages on top of it would duplicate them, and
there is no shared id to reconcile the two by. Attach hands over the rest of the
turn, the half-written paragraph the transcript cannot hold, and the session's
cursor head — that last one so a *later* drop replays from the head instead of
re-delivering the whole conversation from zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:59:26 +00:00
pastilhasandClaude Opus 5 1dc0eddde0 put the open plan in the url, and stop /api/plans reading outside its folder
/plans/:name, no redirect guard: the bare route is 'no plan open', which is a
real state, so the auto-select-first effect is deleted rather than turned into
a Navigate. The picker stays a native select — chrome for one document, not a
master list — but it navigates instead of setting state.

Reading the server route for this turned up a path traversal: hono
percent-decodes params, so GET /api/plans/..%2F..%2Fsecret reached
join(plansDir, '../../secret.md'). basename() the param.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:41:55 +00:00
pastilhas 92b89052a4 make a layout column refuse a non-layout 2026-08-07 09:24:16 +00:00
pastilhasandClaude Opus 5 81ad3ef5ef let two windows onto the same dashboards agree again
The dashboard-state cache had staleTime: Infinity and there is no invalidateQueries anywhere in the
repo, so it was fetched once per page load and never again: two windows diverged permanently and neither
was ever told. It now refetches on focus — with three non-default guards, because this cache is
optimistic and a refetch that started before an in-flight PATCH landed would overwrite the value we
already showed. Never on mount (splitting a panel mounts a fresh consumer, which is exactly when a write
is in flight), never on reconnect, and on focus only after a short quiet period with nothing in flight.

The PATCH stopped assembling a full state blob it then returned to nobody — three SELECTs per splitter
release, thrown away, and a caller that did read it would be reading state assembled before whatever
concurrent write it raced.

And the last three `.catch(() => {})` in this family are gone: dashboard create, rename and delete build
their own multi-key patches and so bypass the hook. They now go through persistDashboardState, which
keeps the in-flight bookkeeping honest and, on failure, invalidates rather than reverts — there is no
single previous value to swap back once the roster has been rewritten, and a refetch is the only thing
that makes the list agree with the server. A failed delete used to leave the dashboard gone from the list
and alive on the server, reappearing at the next reload with no hint why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:15:55 +00:00
pastilhasandClaude Opus 5 f4ed7401da stop the dashboard PATCH dispatcher losing writes
Four defects, one shape: a write that returns 200 and lands nowhere.

- Unknown keys were dropped by a chain of `if (match) continue` with no `else`. The three prefixes
  CommandTerminalWrapper actually writes — tmux, nvim, claude-code — were among them, so those panel
  maps lived in the React Query cache only: every reload minted a fresh uuid and abandoned a running
  pty. They now live in a `panel_state` bag on the dashboard row, and an unmatched key 400s.
- `ws-terminals-{id}: null` fell through to an upsert, writing NULL into a NOT NULL column on a live
  dashboard and re-INSERTing a deleted one. Renaming a dashboard sends exactly that, paired with
  `ws-layout-{id}: null`, so the old slug came back as a zombie row in the dashboards list.
- HostTerminalWrapper and CommandTerminalWrapper built their state key straight from `dashboardId`,
  which is a workspace *key* (`ws-layout-<id>`), while TerminalWrapper stripped the prefix. The server
  read the un-stripped form back as a dashboard id and created it. One rule now, in state-key.ts.

Verified against the live server: unknown key 400s, the three prefixes round-trip, a null on a live
dashboard is a no-op, and the rename sequence leaves no zombie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:55:01 +00:00
pastilhasandClaude Opus 5 969b2f3762 show compaction as it happens and open tool calls while they run
Compaction was the one thing the harness does that emitted nothing at all while it
ran, and it can run for minutes — silence that reads as a hung turn, which costs a
server restart to discover it wasn't. The sidecar now reports both ends: the start
from the PreCompact hook, the finish from the compact_boundary message with the
token count, both durable so a reload or a reconnect still sees them.

Tool rows open themselves while they run and hold for five seconds after their
result, so the inputs are on screen at the moment the call is made rather than
after the fact. The clock lives outside React, keyed by tool call id: rows are
virtualised, so unmounting is not the call ending, and a fast call can render its
start and its result together — a row that only opens when it catches the pending
state never opens for exactly the quickest calls. A click outranks the clock for
as long as the row lives. A failure behaves identically and differs only in colour,
so it stays findable by scanning and nameable in conversation.

Shell logs move off the green-on-black pre onto the shared code surface, which is
the one block that had no copy button and the one you most often want to hand to
someone else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 05:10:22 +00:00
pastilhasandClaude Opus 5 6d0d103c78 carry attached images through to the model
The composer already uploaded an image, split its data URL and put the bytes on the wire as
`images`. Nothing on the server read them. The `chat` ClientMessage had no such field, and the
prompt reached the sidecar as a bare string, so all the model ever saw was the client-generated
`[Attached image: …]` placeholder — a label describing a picture it was never shown.

The transport was never the obstacle: `query()` consumes an async iterable of user messages whose
`content` is an Anthropic `MessageParam`, and only `pushTurn` hardcoding a string kept it to text.
So `images` is threaded through the four hops that dropped it and turned into native image content
blocks at the end, renaming `mediaType` to the API's `media_type` at that last step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:54:39 +00:00
pastilhasandClaude Opus 5 66fca28927 retry a turn the agent restart cut off
the cut-off notice is its own event now rather than an error: nothing is broken
and nothing is lost but the turn, so the row says what happened and offers the
one action that fixes it. the conversation is already durable — the claude
session id is written through to disk and passed back as resume: — so retry
just resends the prompt on a session the fresh agent picks up with full
context. read back out of the transcript, so a second window on the same
session can offer it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:00:16 +00:00
pastilhasandClaude Opus 5 876b39b301 end a turn whose agent has gone
restarting officer-agent takes every persistent session with it and nothing
downstream notices: the browser's socket is healthy, officer's subscription is
a bus filter, and there is simply never another event. the spinner ran forever
and a refresh didn't help, because the transcript has no ending to read.

keyed off the agent *registering*, not disconnecting — a disconnect fires on
every `pm2 restart officer`, when the turn is fine. a registration socket dies
with its process, so an agent appearing on it is a new one. covers the sitting
tab; the reconnect path covers the rest, with the client now sending its belief
that a turn is in flight and officer checking it against the agent over a new
claude:is-generating. the check fails toward alive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:43:01 +00:00
pastilhasandClaude Opus 5 d8ee678ec4 let members sign in to the platform, which is the entire point
signin refused any non-owner arriving through the web or mobile platform
origin: "This account can only sign in through its app." so a member could
hold a gitea grant and still never reach a page — verified as a live 403
before this change.

that rule was correct while single-user was the invariant. the only
non-owner accounts were music-app accounts, and there was no way to say
"this person may use the platform, but only these parts of it", so keeping
them out entirely was the honest answer. capabilities say exactly that now,
per feature, at both doors and on every request.

so superAdminOnly is retired rather than patched. the web origin and the
platform app get no path scoping — what their caller may reach is decided by
their role, not by their Origin. per-app path scoping stays for the
single-feature apps (music, vault, tail), where it still means something.

note this WIDENS who may sign in: any Active account can now authenticate
through the browser. that is the intended product change, and it is only
safe because the capability backstop runs on every request behind it —
which is why it lands after that, not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:36:07 +00:00
pastilhasandClaude Opus 5 29fc9722c1 interrupted by user, not an error
pressing stop ended the turn with "Claude Code returned an error" — the agent
sdk reports interrupt() as an ordinary failed result, indistinguishable from a
real fault downstream. the sidecar now flags the session it interrupted and
rewrites that event to the existing durable 'stopped', which opencode already
emitted. escape stops the turn (bound to the chat subtree, not the document),
and the prompt comes back to the composer verbatim unless you've started typing
something else. history parity: claude files [Request interrupted by user] as a
user message, so the transcript reader maps those exact strings to the same
role instead of replaying them as something you typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:18:57 +00:00
pastilhasandClaude Opus 5 f3a3ae3b64 capabilities: send the denied routes too
the server half of the previous commit, which belonged with it. the frontend
guard needs both lists: absence from `routes` cannot tell a route this
account lacks from a route no capability claims, so without this the guard
permits everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:01:01 +00:00
pastilhasandClaude Opus 5 dbd471c32d capabilities: the api, and one honest exception
GET /api/user/capabilities is what the caller may reach, and every account
may ask — it is mounted on a core capability so an account granted almost
nothing can still find out what it has. the dock and route guards read it.
it is a courtesy, never enforcement: hiding an icon is not access control
and the 403 in origin-validation stays the lock.

GET/PUT /api/users/capabilities edit the policy, owner-gated. the write path
is where the registry's authority over capability keys is applied, which is
why the column has no CHECK: unknown keys and non-app kinds are refused
rather than stored for the resolver to drop on read.

the exception is `selfService`. useAuth calls PUT /api/users to change your
own name and avatar, and that route has always lived on the same router as
the owner-only account administration around it — so declaring /users an
admin capability locked every member out of their own profile. moving it to
/api/user would be tidier and would break every shipped mobile client, so
instead the registry says out loud that this one route is not what the
capability around it is. exact method and exact path, so it cannot widen:
verified that PUT /api/users passes while GET /api/users, PATCH
/api/users/:id/role, DELETE /api/users/:id and PUT /api/users/:id are all
still refused.

23 unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:57:56 +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 236541fa2a chat urls: a group is a path, a session decides its own directory
the chat group moves from ?cwd= to a path suffix behind a g/ discriminator
(/chat/g/home/me/project), and a session url carries no group at all.

the real fix is not the spelling. a session's working directory was read back
off the query string to decide where the agent executes, so the address bar was
the authority on where code runs. a pasted or refreshed /chat/<id> arrives with
no ?cwd= at all, so a turn sent before the resolve landed ran in the default
general_chat_sessions dir instead of the project; and a hand-edited ?cwd= could
name a group the session doesn't belong to, with nothing to reconcile them.

loadClaudeSessionById already resolves a session's cwd from the id alone, so the
id is the only source of truth there. it now travels on SelectedSession.cwd,
which is what the composer reads. the url can no longer contradict it.

the vocabulary lives in apps/ChatHistory/chat-routes.ts so a link built in a
panel and one built in a screen cannot drift.

also: startAgentRun no longer returns a literal chatUrl — it returns cwd and
AgentRunnerModal builds the link, so the server holds no copy of the frontend
url shape. and the post-turn permalink strips a stale ?cwd= instead of carrying
it forward onto the new session's url.

walkthrough doc gains item 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:59:39 +00:00
pastilhasandClaude Opus 5 10ff23c5dd gitea: the app — repos, code, issues, pulls, notifications
Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through.

Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits,
branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications,
explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item,
all real Links with the URL as the source of truth — no selection channel.

Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw
deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that
means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost
is Gitea's #123 and @mention cross-references; relative links and images are resolved instead.
The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands.

retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY.
This instance answers with two: /user and /repos build from its configured ROOT_URL
(http://localhost:9004), /contents from the public host. An unconditional rewrite onto the
connection URL therefore broke the second set to match the first, turning working https links into
dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is
itself public; when the connection URL is a dial address nothing is touched and the connection
screen says why avatars will not load.

Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a
URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is
string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather
than connection.url, which is null for a member.

Not verified: no runtime pass since the last four changes, the issues and pull views have never
rendered a row (the instance has none), and the member path has never executed (one account).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:10:36 +00:00
pastilhasandClaude Opus 5 e54d71da71 api: five reads that were declared as writes are now GET
The permission model being built reads the HTTP method to decide whether a
non-owner may make a call: safe methods are reads, everything else is a write.
That only works if the method tells the truth. These five read something and
returned it while announcing themselves as writes, so a member would have been
denied a read they are entitled to because of a habit in how the route was
declared.

  /api/file-browser/video-info         POST {url}  -> GET ?url=
  /api/file-browser/video-playlist     POST {url}  -> GET ?url=
  /api/server-settings/ocr/models      POST {url}  -> GET ?url=
  /api/transmission/_officer/port-test POST        -> GET
  /api/jellyfin/_config/:id/test       POST|GET    -> GET only

The last one already answered to both, which is worse than either: a method that
means nothing cannot be the thing authorisation reads.

Deliberately stops at five. A sweep of all 100 mutating routes found many more
reads wearing POST, and they are staying, for two reasons that are not going
away: some need a request body GET cannot carry (/stt takes multipart audio;
/tts, /ocr, /transcribe take payloads), and some carry a credential, where a
query string is the wrong place — access logs, shell history and Referer headers
all capture those, request bodies do not (/tts/voices takes an apiKey, the four
/test endpoints take connection secrets, /local-providers/probe takes auth).

So the method alone can never carry the permission model, and the registry will
need an explicit per-route classification regardless. Converting these five is
worth it because it is free; converting the rest would be a breaking change
across 117 mobile call sites that buys nothing.

Web callers updated in the same commit; the sidecar contract comments now match.
Mobile has exactly one caller to change — transmissionPortTest in
packages/core/src/services/transmission.ts — and no shim was added, because an
endpoint answering to both methods is the problem this commit exists to fix.

docs/api-method-changes-2026-08-06.md is the handoff for the mobile team: what
changed, the one line to edit, what deliberately did NOT change and why, and how
to verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:38:49 +00:00
pastilhas 1eb2140f95 log every auth attempt that names an identity the platform does not know 2026-08-06 05:31:10 +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 5 904edefd62 jellyfin sidecar: server registry, video façade and byte pass-through
officer-jellyfin owns the whole Jellyfin contract: the instance URL, the access
token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed
by. The platform side is a 17-line proxy holding no credentials.

Servers are a registry, not a single row — this machine runs four instances and
the owner switches between them. The password is never stored: it is traded once
for an access token through AuthenticateByName, and only that token is persisted,
encrypted.

Two doors. /_officer/* is a hand-written JSON façade for the things the browser
should not have to know — the user id in the path, the Fields lists that decide
whether a grid has posters, the PlaybackInfo negotiation. /_jf/* is a GET-only,
allow-listed byte pass-through for images, video, HLS and subtitles; it keeps
Jellyfin's own paths because a master playlist references its segments
relatively, so any renaming would mean rewriting m3u8 bodies.

TranscodingUrl arrives with api_key=<access token> in its query string and would
otherwise be handed straight to a video element. It is stripped before anything
is returned; the pass-through re-adds the credential as a header.

Video only — Officer's own player owns audio, so music collections are filtered
out of the library list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:59:50 +00:00
pastilhasandClaude Opus 5 a328451250 add /settings/user-management
The role column now has somewhere to be used from. Lists every account, changes
roles, removes members.

API — all owner-only, mounted on the existing users router:
  GET    /api/users            list, plus the role enum so the UI never
                               hand-writes the names
  PATCH  /api/users/:id/role   change a role
  DELETE /api/users/:id        remove an account

ownerGate uses isSuperAdmin, which now reads the role column. The global backstop
in originScopeMiddleware already confines a non-owner token to /api/auth +
/api/music, so a Member cannot reach any of this — the gate is the explicit
statement of intent and gives a clear 403 rather than leaning on a rule written
for another purpose.

The password hash never leaves the handler: listing accounts is not a reason to
hand out hashes, so the response is an explicit shape rather than the row.

The owner is refused twice over, in both handlers, before the database has to.
ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a
raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person
clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks
that row rather than offering an action that cannot succeed.

The menu entry is shown to the owner only. That is tidiness, not access control —
the route stays reachable and the endpoints are gated server-side, because a
hidden menu item is not a permission and anything relying on it being hidden is
already wrong. Said so in the code, next to both.

Deleting cascades — passkeys, dashboards, screens, email accounts, playlists —
and there is no undo, so it asks first and says what goes.

Not built: invitations. Creating an account still means bootstrap or a row by
hand; an invite flow needs a token, an email and an acceptance screen, which is
its own piece of work.

Untested at runtime: the routes 404 on the running server because platform TS
does not hot-reload. Everything typechecks, the token path was verified against
/api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the
restart asymmetry rather than the wiring. Needs `pm2 restart officer`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:57:48 +00:00
pastilhasandClaude Opus 5 8de3b1908c auth: the role column decides who owns the server
There were three answers to "who is the owner" and nothing kept them agreeing:
SUPER_ADMIN_EMAIL in .env, the lowest user id, and now the role column. Any two
of them part company the moment one is edited, and the failure is silent — an
account quietly gains or loses the vault, the platform origin, and the identity
the agent sidecar runs as. The column wins; the other two are gone.

- isSuperAdmin() reads users.role. SUPER_ADMIN_EMAIL is deleted from the code and
  from .env.
- getOwnerUser() selects on the role instead of `order by id limit 1`. It returns
  undefined when no row holds it rather than falling back: the agent sidecar
  refusing to start beats it silently running as the wrong person.
- bootstrap creates the first account with role 'Super Admin'. Without this a
  fresh install would take the column's 'Member' default and come up with NO
  owner at all — no vault, no agent identity, and the web origin locked to a
  Super Admin that does not exist. That bug was live the moment the column
  landed; SUPER_ADMIN_EMAIL was masking it here.

Deliberately not cached. The old resolver cached an owner id, justified by "the
owner never changes at runtime (bootstrap is closed after user #1)" — which stops
being true as soon as roles are editable, and a cache with no invalidation
contract is a staleness bug waiting for whoever builds the role UI. It is one
primary-key lookup on a request that has already verified a JWT.

Verified against the live database: getOwnerUser resolves to id 1, isSuperAdmin
is true for it and false for the three Members, for a null payload and for an id
that does not exist. Then temporarily set id 13 to 'Admin' (false) and to
'Super Admin' (true) with no restart in between, which is what proves the column
is doing the deciding rather than a cache or the old lowest-id path. Reverted.

Not solved here: nothing stops the last Super Admin being demoted, which would
leave the platform ownerless. The schema cannot express it; whatever eventually
edits roles has to. Noted in the column's comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:42:35 +00:00
pastilhasandClaude Opus 5 3f57cec551 one-tap ios dav provisioning
mints a dav app password, renders a configuration profile carrying both the
caldav and carddav payloads, and parks it behind a single-use five-minute token
that safari can fetch without a session.

one profile with both payloads is not a convenience: ios keys accounts by
server+username, so adding carddav separately gets folded into the existing
caldav account and contacts silently never appear.

the profile holds the password in plaintext, so it is held in memory only —
persisting it would falsify createDavAppPassword's "not stored" guarantee.

signing is opt-in via DAV_PROFILE_SIGN_CERT/_KEY/_CHAIN and off by default;
this box has no tls certificate, tls terminates upstream. signed at mint time
reading the cert from disk, so a renewal needs no restart and no hook.

the download route is registered before the /dav mount because hono matches in
registration order and the sync door's /* would otherwise demand http basic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:20:52 +00:00
pastilhasandClaude Opus 5 99f1006669 auth: drop the user environment provisioning
provisionUserEnvironment seeded a managed home under DATA_PATH/<email> — shell
configs from a template dir, plus a generated CLAUDE.md and settings.json — and
bootstrap called it fire-and-forget when the owner account was created. It is
outdated: the managed home is not where anything runs. Task runs, terminals and
chats all execute in getOwnerHomeDir (the real login home, HOME_DIR), not the
getHomeDir tree this was populating.

Removed the function, its four template files, and the call in bootstrap. No
setup script referenced it — checked all of scripts/*.sh — and bootstrap was its
only caller anywhere in the tree.

getHomeDir and toShellUsername stay in data-path.ts: pipeline-executor and
pipeline-job-manager still use them.

This leaves src/servers/generate-container-context.ts with zero consumers, since
provision was the only thing importing it. Left in place rather than deleted in
the same commit — it is 173 lines that build a CLAUDE.md and a settings.json for
an agent, which is plausibly wanted somewhere else, and that is a call for the
owner rather than a side effect of this cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:31:30 +00:00
pastilhasandClaude Opus 5 75b3484636 log every dav request, and every rejected credential
a DAV client tells you almost nothing when it fails — iOS reports every setup
failure as "Cannot connect using SSL" regardless of cause — so the only way to
know whether a phone ever asked for an address book is to record that it did.

one line per proxied request with the client's own user-agent, and a warning
when a credential is present but wrong. a missing credential is the normal
opening move and stays unlogged; a wrong one is indistinguishable from it at
the client end, where both just say "password incorrect".

no body is ever logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:19:50 +00:00
pastilhasandClaude Opus 5 3770d7c647 re-adopt orphaned chat sessions on reconnect
restarting officer under a live turn left the browser connected but permanently
silent. the sidecars are pm2 peers, so the agent kept generating and kept
committing to chat_session_events — what died was officer's binding to it. on
`resume-cursor` the server only re-attached the socket when an in-memory session
still existed, so after a restart there was no session and, critically, no
session-scoped subscription relaying sidecar events to the client. the client got
its durable replay and then nothing, which reads exactly like the agent stopping.

adopt the session instead: recreate the record and re-open the subscription
without spawning anything. `_claudeKill` has to be set as part of that — handleChat
treats its absence as "first turn" and would open a second subscription, doubling
every message.

the client now echoes the model and cwd from its session:init back in the
handshake, since after a restart it is the only party that still remembers them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:36:02 +00:00
pastilhasandClaude Opus 5 edf26323da memos sidecar
wraps the self-hosted memos instance, same shape as transmission and slskd. no
schema change was needed: service_connections already says `service` is text
because "adding a service should not be a schema change", and memos is the
one-instance-per-owner case that table was built for.

the sidecar holds the url and the personal access token; the platform side is
16 lines of createSidecarProxy and holds neither.

/_api/* is a pass-through onto the instance's own /api/v1 rather than a
hand-written wrapper per endpoint — memos generates its rest api from protobufs
and it moves between minor versions, so re-describing it here would be a second
thing to keep in sync. the allow-list is the one piece of policy, and it keeps
this from being a general ssrf hop. auth routes are excluded: signin/signout
would mint sessions on the instance, and this authenticates with a stored token.

probing is two calls on purpose. /healthz answers unauthenticated, so a bad url
is distinguishable from a bad token — memos returns 200 and an empty list for
unauthenticated reads rather than 401, so "the list came back" proves nothing.

verified against the live container: unconfigured reports not-connected, a bad
token is rejected WITH the reason and nothing is stored, and the platform mount
401s without a session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:24:15 +00:00
pastilhasandClaude Opus 5 36c7b1f3fd delete api routes with no consumers
from a full audit of all 179 route definitions under src/servers/api, tracing
consumers through useClient, raw fetch, EventSource, the capabilities repo and
the mobile monorepo. only routes with zero consumers anywhere are removed.

  server-settings/applications.ts   whole file — an app install/update registry
                                    with no settings section to drive it
  server-settings/claude-code.ts    whole file — the ai settings screen talks
                                    to chat-providers/* exclusively
  GET  browser/extension-download   superseded by a static asset; BrowserRelay
                                    links at /browser-relay-extension.zip
  GET  integrations/                a stub returning []
  GET  chat-providers/auth          /api-keys says the same thing with more detail
  GET  desktop/vnc-status           and with it the vnc:status command and reply,
                                    which existed only to serve this route.
                                    docs/sidecar-audit-2026-07.md called this
                                    one dead months ago

deliberately KEPT, because "no caller" turned out not to mean "dead":

  POST activity/announce      not orphaned — it is the missing PRODUCER for the
                              detached[] list GET activity/tasks already returns
                              and ActivityScreen already renders. an unbuilt
                              feature, not dead code, and finishing or dropping
                              it is a product decision.
  GET  agents/runs            three days old. part of agent grounds, still being
                              built. "not yet consumed" is not "dead".
  PUT/GET vault/unlock-key    six days old, storage half of a feature whose
                              client half is unwritten. the vault is off limits.
  DELETE integrations/google/connection   caller exists but is deliberately
                              commented out of the tree. dormant on purpose.

vnc-manager's getSession is now orphaned too, but it is sidecar-internal and
was not in scope; noted rather than chased.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:19:37 +00:00
pastilhasandClaude Opus 5 ccd104a28b caldav/carddav: officer-caldav sidecar and the /dav door
first two steps of docs/nextcloud-replacement.md — the half that has to work on
a phone, because that is the half that cannot be faked.

radicale is supervised by the sidecar rather than reimplemented. nextcloud does
not implement caldav either; it vendors sabre/dav. icalendar and vcard are a
weekend, but sync-collection, rrule expansion, vtimezone and ctag/etag are not,
and when they are subtly wrong a phone does not error — it silently stops
syncing, or silently duplicates every event.

two doors, because a browser should not speak dav:

  /dav/*        top-level, http basic against a scoped app password, every
                verb and every dav header forwarded verbatim. this is what
                davx5 and ios talk to. same reasoning as /api/vault being
                mounted outside protectedRouter.
  /api/caldav/* the ordinary sidecar proxy, for officer's own ui. json.

the shared proxy factory could not carry the dav door: it forwards three
headers and dav dies without Depth, and it derives the user from a jwt a phone
cannot hold. so it is a separate file, per that factory's own instruction never
to grow per-app logic.

new `dav_app_passwords` — a phone cannot do jwt, and the alternative is the
account password living in a phone's account manager. argon2, shown once,
revocable per device, and accepted ONLY by /dav.

.well-known/caldav and carddav redirect to the dav root. they are most of what
makes adding an account feel transparent, and they need naming explicitly in
server.tsx or the SPA `/*` fallback answers the phone with html.

verified end to end against the running stack: 401 + WWW-Authenticate
unauthenticated; 207 with calendar-access and addressbook advertised; MKCALENDAR,
PUT and GET of a real VEVENT; calendar-query and sync-collection REPORTs; MKCOL,
PUT and GET of a real vCard. X-Script-Name is set because radicale otherwise
generates hrefs at / and the client follows them into the SPA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:16:17 +00:00