2aef23c8ba0fee37a78701612128a194194b8096
68
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2aef23c8ba |
a commander starts its own conversation, instead of being handed one
Starting a commander meant writing a good opening prompt, which is the hard part of the whole thing — so in practice somebody else wrote it and pasted it in. That is not a flow, it is a person acting as the intake form. Now the two decisions that cannot be guessed stay with the owner (a name and a working directory) and the rest is a button. It sends a brief the MODEL reads — "I have not told you what I want yet, orient yourself and ask me one question" — while the transcript shows "Set me up.", which is what a person would have typed. That split is `effectivePrompt` vs `displayText` and the chat path already had it. What to ask, in what order, and to read the directory BEFORE asking anything, all live in the Commanders skill under "When you started yourself". Deliberately not here: capabilities are re-read from disk every run, so rewording a question costs nothing, while the same words in commander-block.ts cost a restart each time. The block carries a pointer and no doctrine. Orienting first is what makes this work on a real request. Told "automate the platform deploys", a commander that has read the directory finds `deploy/`, the previous releases and the deploy skill, and asks whether that is still how you want it — instead of inventing a scheme next to the one already there. `sendPrompt` gained an options object rather than a tenth positional parameter. Nine is already past where a call site reads as a row of undefineds. The composer stays enabled beside the button: typing your own opener still works, it just is not the only way in any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ae27478a27 |
the platform keeps its own transcript-id mapping, because the sidecar's is invisible to it
The commander block still was not arriving. Not the delivery mechanism this time — the LOOKUP. Building the block needs the transcript id, the platform had only officer's session key, and it asked the sidecar's `state.ts` for the mapping. That map lives in the sidecar's memory; the platform process has its own empty copy of the module, and re-reading the state file from here does not find a live session either. Verified both ways before changing anything. So the platform keeps its own map, filled from the `session:claude` message it already handles — the one moment the mapping is announced. It is not derivable from anything: it is stated once, mid-turn, and whoever needs it either records it as it passes or does without. The delete route had the identical problem for the same reason and now asks the platform's map first, keeping the sidecar lookup as a fallback for a session that predates this process. Third distinct bug today from these two ids, and they are all the same shape: a lookup keyed on one id space against data held in the other, failing silently because both are UUIDs and neither side can tell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aad382f4be |
a commander is told where to record itself on a TURN, because the preamble never reaches it
Its own first test caught this: the commander armed a perfectly good watcher, established a baseline correctly, and wrote no loop-pid — so deleting it would have left the loop running. The block was composed into the system preamble, which `createSession` builds ONCE, on a conversation's first turn. For a commander that is exactly the turn where the block cannot exist: no command_hq_sessions row, no transcript id, so `commanderBlock` returns null. Every later turn reuses the session and never rebuilds the preamble. So a commander never received it at all — the "first turn gets no block" limitation I closed with a rule was really "no turn ever gets it". It rides `effectivePrompt` now: the string the model sees, as against `displayText` which the transcript shows. Same seam the background-job dialog already uses for its hidden preamble, so nothing new — the agent gets the paths without them appearing in the conversation. Once per session, on the first turn where the block resolves, and it stays in the conversation's history from then on. `briefedSessions` is in memory, so a restart re-briefs. That is the safe direction: a few hundred tokens on one turn against a commander that was never told and therefore cannot be stopped. The preamble path is removed rather than left dormant — a dead branch that reads as the mechanism is how this went unnoticed in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fc8ce04f2b |
the commander's name shows the moment it is typed, not after a refresh
Naming works — confirmed on a live commander, which now carries `{"type":"summary","summary":"Inbox
Summarizer"}` in its transcript. The instrumentation showed exactly the predicted shape: two attempts 2.8
seconds apart, the first on `session:claude` before the transcript file existed and the second on `result`,
which is the one that landed. The retry was the fix; the guess about WHY was right and the evidence was worth
having rather than assuming.
What was left was cosmetic and looked like the same bug: the pane still read "Untitled commander" until a
refresh. It could not have read anything else — the rename lands at the end of the first turn, and this bar
has no fetched title because a brand-new commander has nothing to resume. So it showed the one thing on
screen nobody had chosen, while the chosen name sat in a field two inches above it.
It takes the name from state now, which is where it already was.
Instrumentation removed. It earned its place: three attempts at this bug were reasoned and two of them were
wrong, and the log settled it in one run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
cb51f35cad |
deleting a conversation forgets what the platform stored about it
Found by a sanity check after two commanders were deleted through the UI: the Command HQ tables cascaded perfectly and four OTHER tables kept a row each, for ever. 24 orphaned rows in chat_session_models, 26 in chat_session_events, against 14 live transcripts. Nothing cascades because there is nothing to cascade FROM. All four are keyed on a transcript id, and a transcript is a file the harness writes — there is no row for a conversation, so the database cannot help and the delete path has to be explicit. It was not. Three of the four are inert: a model, a bell preference, a durable copy of turn output nothing will ask for again. `nudges` is not — it is a SCHEDULE, read by the tick in nudger.ts. A nudge belonging to a deleted conversation is a timer that keeps firing at something that no longer exists. That is what turned this from untidy into worth doing now. deleteClaudeSession returns the IDS it removed rather than a boolean, because a `/clear` chain is several transcripts presenting as one conversation and forgetting the head while orphaning its parts is the same bug at a smaller scale. THE TRAP THAT CHANGE INTRODUCED, caught before shipping: every call site said `if (deleteClaudeSession(...))` and an empty array is TRUTHY in JavaScript, so a delete that found nothing would have reported success. The typecheck was clean — `string[]` is a perfectly good truthy expression. All three sites test `.length` now. Fifth silently-wrong thing today that a typecheck could not see. Cleared the existing orphans with the same function the delete path now uses, after checking the one thing that would have made it wrong: whether any of them belonged to a member, whose transcripts live in a home this scan does not read. There is one account on this machine, so they were all the owner's. Two chat_session_events rows appeared again immediately after, and are not a leak: the durable log is written independently of the transcript — that is what makes turn output survive officer being down — so a session that has just begun legitimately has events before a transcript exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2bd8701b15 |
a commander runs where the owner chose, and Command HQ stops scanning one folder
Two of the three things the identity change unblocked. THE WORKING DIRECTORY. `resolveChatCwd` forced `getCommandersDir(email)` and ignored `msg.cwd` entirely — every commander shared one empty directory whose only purpose was to BE a cwd, because the cwd was how a commander was recognised. It honours the choice now, resolved through the same `resolveCwd(home, …)` containment the ordinary chat branch uses, so "client-chosen" still cannot mean "anywhere outside the account's tree". No fallback to the old shared folder: the UI will require a directory, and an absent one falls back to the account's home, which is the honest last resort rather than a silent special case. THE LIST. Command HQ found its commanders by scanning that one directory, which would now find only the ones that happen to still live there and silently hide every other. It reads its rows and loads each transcript by id — `loadClaudeSessionSummaryById`, added here — a handful of lookups rather than a directory walk. A row whose transcript is nowhere is treated as absent exactly as a missing file was. `POST /purge` had the same scan and it was the dangerous one: unfiling every commander that chose a different directory, from a housekeeping route, when the rows are what identity now depends on. Three smaller corrections that came with it: - rename and delete passed the commanders directory as their cwd argument. It is documented as a HINT that falls back to locating the transcript anywhere, so they worked — but passing a path we know is usually wrong reads as intent later. They pass `who.home` now. - `POST /command-hq/commanders/new` returned `cwd: getCommandersDir(...)` regardless of where the commander actually ran, and now accepts `cwd` and `name` like the UI will, and reports what it was given. - the router's "Where commanders live" section said every commander shares one directory and that listing is therefore a scan. Both sentences were the design being replaced. `loadClaudeSessionSummaryById` takes its mtime from `listTranscriptsAs` rather than a bare stat: the file may belong to a member, and reading a member's tree needs the as-user path. That is one scoped directory listing per commander — the same call the old whole-folder scan made, once per row instead of once for all, which is the price of letting each choose where it lives. Verified on live data: the existing commander still resolves, with its title and its real updatedAt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d08ae41f1f |
a commander can be named before it says anything
Claude Code will not accept a name for a new session — the harness names its own from the first prompt — so the platform renames the transcript the moment it exists. The owner names the commander up front, beside choosing its working directory, and never sees the rename happen. It works because of what rename actually writes. `renameClaudeSession` stores a `summary` record, which is what sets `hasExplicitTitle`, and an explicit title is never overwritten — not by the harness's `ai-title`, not by the first-message fallback. So the chosen name wins permanently rather than racing the titling. That property already existed and was documented; nothing needed adding to make it hold. Applied SERVER-side, in `fileAsCommander`, on the `session:claude` that first carries a transcript id. Not by the client, because it must not depend on a browser still being open: the name is chosen before the first message, and losing it to a closed tab would leave a commander called after whatever it happened to say first. `chatIdentity` is exported and reused rather than reconstructed. It decides the home and the OS user, and a rename writes a file as that user — a second copy of that resolution is the kind of thing that works until a member uses it. The wire field is carried on the turn rather than sent afterwards, for the same reason the plan-mode flag is: a brand-new conversation has no live session to configure and no stored row to update, so its first message is the first moment anything can be acted on. UI comes with the directory picker — both are chosen in the same place, and neither is optional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
44a3d5f655 |
fix(chat): give AskUserQuestion a real answer channel
The harness answered every question itself, instantly, with "The user did not answer the questions." — before the card had painted, let alone been read. The agent then reasoned about a refusal that never happened: on 2026-08-19 it asked the same question three times in a row while the answers sat on the screen. The web UI already worked around it by rendering the card ungated and sending the click back as an ordinary chat message. That got an answer through, but the agent read it as a new instruction arriving from nowhere rather than as the reply to what it asked, and nothing could distinguish a live question from one answered last week — every replayed transcript rendered its old questions as clickable. A PreToolUse hook is the fix, and it has to be a hook rather than canUseTool: under bypassPermissions — which is every session here — the SDK shadows the callback and never calls it. Measured against 0.2.59: a bypassing session's AskUserQuestion reached the hook and never reached canUseTool. Hooks run regardless of permission mode. The hook parks on a promise keyed by the harness's own tool_use_id, and returns `deny` with the human's answer as the reason — which the harness feeds back as the tool result verbatim. "Deny" reads wrong and is right: what it denies is the tool's ability to answer for the user, and what replaces its output is what the user actually said. Held open for 150s in testing with the turn completing normally, so a person has time to think. Keying on tool_use_id rather than an invented id means no new durable event: the questions are already in the session log as an ordinary tool:start, and the answer arrives as its tool:result. One source of truth, replay included — which is what re-enables gating the card on `output`, and with it the staleness fix. The two tests that pinned the old ungated behaviour asserted the workaround as the contract; they now pin the reversal, including a pre-fix transcript reading as settled, which it is. Unanswered questions settle like plans do: 20 minutes, every session-ending path draining them first, and wording that tells the agent the question is still on the user's screen and must not be asked again. Mobile (Butler) has the same workaround and is not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
963c965056 |
command hq: auto-name a commander from its watcher's own task description
The initial title (the mechanics preamble, truncated, when a background job is
the very first message) is expected — that's just Claude Code naming the
session from its first message, same as always. But by the time the watcher's
task actually starts, the harness has already handed it a real description
("Watch google.com reachability" for a ping watcher), and that reads far
better than the preamble ever will.
Hooked into the exact moment pendingArmWatcher is consumed — the FIRST arm,
never a self-re-arm, which the watcher-loop code already had to distinguish
for its own reasons. Resolves a fresh ChatIdentity via resolveHomeDir and
calls renameClaudeSession with msg.description. Best-effort: a failure is
logged and does not touch watcherArmed.
Known gap, noted in the doc rather than fixed: the detail panel's title bar
is fetched once on mount and won't live-refresh when this fires while it's
open. The grid's cards don't have this problem since GET /commanders already
polls every 15s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
764ba23206 |
command hq: per-turn cost ledger, watcher fires as their own table
The owner asked for individual turn token/dollar costs, a running total, cost over time, and cost per trigger — derived, not stored, following the same rule `running` already uses. Two new tables carry it: - command_hq_watcher_fires: one row per trigger (sessionRowId, taskId, firedAt). Replaces the single watcherLastFiredAt column, which lost the previous fire's timestamp every time a new one landed — fine for "when did it last fire", useless for "how many times, and what did each one cost". - command_hq_turns: one row per completed turn (sessionRowId, fireId nullable, inputTokens, outputTokens, totalUsd). Nothing pre-summed — running total, cost-over-time and cost-per-trigger are all queries over this table. Turn count needed no column either: COUNT(*) already answers it, confirmed explicitly with the owner rather than assumed. command_hq_sessions gains watcherArmedAt/watcherStoppedAt in place of any "last fired" cache — set only on the false<->true transitions, so duration (current run, or the last completed one) is computable without a full transition history table. Two new read routes, GET /commanders/:sessionId/turns and .../fires, expose the raw series behind the cost summary and watcherLastFiredAt (now computed via MAX(fired_at), not read off a column — the field survives in the JSON response under the same name). Bug found and fixed while wiring this up, recorded in docs/command-hq.md: recordCommandHqWatcherFired's signature changed from (userId, sessionId) to (sessionRowId, taskId) and one call site kept the old argument order — tsgo stayed clean because number/string still matched structurally. Caught by re-reading every call site by hand, not by any tool. Needs bun db:push before any route answers with real data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
624d67ed52 |
watcher loop: the agent re-arms itself, same turn — final mechanism, confirmed against real usage
second correction to the re-arm design tonight, and this one came from the owner, not from me. my plan (platform sends a synthetic follow-up turn once the acting turn's result lands, instructing re-arm) was technically sound — it keeps the agent inside the sdk's own Monitor tracking, which is the thing the FIRST wrong plan (silent respawn) broke — but it costs a real extra turn every cycle. pushed back on with hard evidence: the mobile dev's own watcher, on this repo, this session, had been running with THEM re-arming themselves, same turn, for hours already — zero failures. that beats a reliability concern I raised without having tested it. so: handoffContract now instructs the agent to re-arm itself, as the last action of the SAME turn that handles the notification — no synthetic turn, no platform spawn, no extra cost. exitContract's handoff branch corrected to match (was still saying "the harness re-arms a firing on its own", exactly backwards now). server side simplifies to match: createMessageHandler's task:started hook now handles both cases with one rule — pendingArmWatcher (session-object flag, not closure-captured, since arming happens turns after the closure was built) claims the FIRST task:started as the watcher; every SUBSEQUENT task:started while already armed is read as the agent's own self-re-arm and just updates watcherTaskId to match. task:notification records the fire when it matches the tracked id. the platform never drives anything — it only notices what the agent already did. pulled COMMS/command-hq/mobile-3.md, which answered the PREVIOUS (synthetic turn) version — crossed in transit. their transcript-bloat concern is moot now (no synthetic turn exists to bloat anything). their `watcherArmed && !running` detectable-failure pair stands regardless of which mechanism won and is exactly right — kept as-is, matches their own three-state dot (running/armed-but-not/neither). replied in platform.md. all four proposal items now built: prompt+dialog, stopped-vs-died, the re-arm mechanism (this commit), arming state+plumbing. bunx tsgo clean, suite unchanged (1038 pass, same 7 pre-existing baseline failures). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f369b4ca18 |
watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it
item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.
command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).
armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.
the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.
so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.
docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.
bunx tsgo clean, suite unchanged at baseline (1038 pass, same 7
pre-existing failures).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7ad64445ee |
command hq: POST /commanders/new, for a client that cannot open the websocket
mobile's need, decided with the owner in COMMS/command-hq/mobile.md, now
built: { prompt (required), categoryId? } -> { sessionId, cwd }, sessionId
being claude's REAL transcript id, not a placeholder.
the owner confirmed prompt is required and described why: a commander
usually starts as an ordinary back-and-forth before a background job is ever
armed via the existing dialog, sometimes it's just "you know what to do" —
either way there's always a real first message, same as any conversation.
no empty-commander case to design around.
getting a real transcript id costs actually starting the turn, since claude
assigns one only once system.init has been reported. extracted the
turn-starting half of handleClaudeCodeChat into startClaudeTurn(ws: ... |
null, ..., onOutcome?) so both the websocket path and this endpoint drive
one spawn function instead of two that drift — handleClaudeCodeChat is now
a thin wrapper with an unchanged signature. onOutcome mirrors every
sendToClient(ws, {type:'error',...}) refusal point (member not signed in,
claude not installed, spawn failure) plus the session:claude success case,
so an http caller gets the real reason instead of a generic 30s timeout.
the category-write race: fileAsCommander (same hook the web path uses,
since context: 'command-hq' is set either way) always files with no
category. the endpoint's own createCommandHqSession is upsert-safe
regardless of which insert wins, and an unconditional setCommandHqSessionCategory
UPDATE runs after — always last, always wins, so the requested category is
what's left standing no matter the race.
said plainly in COMMS/command-hq/platform.md: this refactor of live spawn
machinery did not get the second-pair-of-eyes review i said i wanted before
touching it. bunx tsgo clean, suite at the same 7 pre-existing baseline
failures. not exercised against a running server on either side yet.
docs/command-hq.md updated throughout — API table, "how a commander is
created", "still open" now empty of blockers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e9b1ce5e94 |
command hq: the web creation path, and a member could not have used it
`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.
found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.
the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.
pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).
docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.
bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
eb94af87f4 |
command hq: the api, mounted and documented
full REST surface for categories, tags and commanders. core permission (personal: ['/'], no grant to hold — every query is scoped to the caller's own user_id at the sql layer regardless). registered in the permission registry and PROTECTED_MOUNTS together, so totality holds by construction. GET /commanders is the grid endpoint: everything /chat/sessions already returns per session, plus categoryId/tagIds/running. `running` is derived live off collectRunningTasks (now exported from websocket.ts) folded over chat_session_events — not a stored column, on purpose, matching the design doc's stance that a status field would go stale exactly when a sidecar restart silently kills a watcher. GET /commanders/:sessionId deliberately does NOT duplicate the transcript — that stays GET /chat/sessions/:id, unchanged. a client calls both. /chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds, .catch()'d so a hiccup on this feature can't take the whole chat list down) — the other half of the one predicate this whole design turns on. there is deliberately no POST /commanders/new: creation is an ordinary chat turn in the commanders directory, which is the next commit. docs/command-hq.md gains an API reference table for the mobile dev, and a status line at the top saying plainly what is real and what is not yet: needs `bun db:push` before any route returns real data, frontend not started. bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all registry/totality drift and the pty socket test — none touch this router). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
77f90a91f0 |
show the model that answered instead of the bare harness string
the picker rendered the literal `claude-code` for any chat that named no
tier. that is DEFAULT_MODEL — the bare harness — and it routes correctly
(no --model is passed, so the cli picks for itself) but names no catalogue
entry, so radix matched no item and the fallback from
|
||
|
|
98c37c4ce9 |
email leaves the platform for plugins/email
the sidecar, the router, the table, the screens and the account manager are
gone from src/ entirely - deleted, not commented, because a commented mount is
what assertPermissionTotality cannot see.
three reverse imports had to go first, and they are what decided the boundary.
core may not import a plugin's queries, and one of these had already proved
why: getEmailAccounts was awaited at the top level of the agent sidecar, so
when the table went away on 2026-08-13 the rejection took officer-agent into a
pm2 restart loop with every live chat session inside it (
|
||
|
|
1b811dfa28 |
make plan mode reachable in chat
opusplan has been in the picker since
|
||
|
|
773f0e1c8b |
never store a model the picker cannot render
The selected model went blank after a turn: visible only by opening the dropdown.
`officerModel` was coming back as `claude-code` — the bare harness. That is `DEFAULT_MODEL`, used when the
client sends no model and the user has no default. It ROUTES fine (isClaudeModel is a prefix test, and
split('/')[1] is undefined so the CLI picks its own), but it names no catalogue entry, so Radix's
`SelectValue` matched no item and rendered nothing.
It fed itself. A blank picker sends no model, so the next turn fell back to the bare harness again and
overwrote the real value — it destroyed a correctly backfilled `claude-code/opus` within one turn. My own
write path did that, added this afternoon.
Two changes. The server refuses to record a model with no tier: a bare harness means "no explicit choice",
and the honest response is to leave the previous answer alone rather than overwrite it with a non-answer.
And the trigger no longer trusts that its value names an item — an unmatchable value renders as its own
string instead of as an empty control, which also covers a retired model or a vanished OpenCode provider.
Repaired the one corrupted row in place by deleting it, so the transcript family fallback answers for that
session until its next real turn; it now reports claude-code/opus again.
|
||
|
|
fc67973876 |
record a resumed session's model when it switches, not when the turn ends
`createMessageHandler` is built per turn, so `transcriptId` started null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was nothing to key the row on. A resumed conversation therefore recorded its model only on the closing
`result`, and a turn that died took the switch with it.
Observed rather than reasoned: the sidecar log said `claude-code/fable -> claude-code/opus[1m]` while the
stored row still said `claude-code/opus`.
Seeded from the resume id, which the Claude path already has in scope for the spawn. A brand-new
conversation still waits for `session:claude` — it has no id yet by definition — but resumed is the case
where switching actually happens.
Also lands docs/chat-sessions-list.md: the plan of record for the sessions-list revamp, agreed with the owner
today. Six numbered targets, four decisions with their reasoning, and the landmines — chiefly that merging
the two left-column panels strands `chat-live` in every persisted layout, which is the same stale-appType
shape that made /offscale render three server pickers this morning (
|
||
|
|
813bc200bf |
a session remembers which model it is using
resume a conversation and the model picker showed the first entry in the catalogue instead of the model the session was actually on. that was invisible for as long as the first entry was `opus`, and became obvious the day `fable` went above it — so this is a pre-existing bug my model-list change exposed, not one it introduced. two defects, stacked. the value did not survive a refresh: `initialModel` came from `props.model ?? locationState?.model`, and both are volatile — a panel channel and react-router location state. on a hard load neither exists, the settings default is skipped (it only runs for NEW chats), and ModelSelector's fallback effect fired `onModelChange(providerModels[0].id)`. and even when it did arrive it could not match. a transcript records the API model; the picker offers CLI aliases. `claude-opus-5` is in a different id space from `claude-code/opus[1m]`, and the mapping is one-way: opus and opus[1m] both serve claude-opus-5, opusplan serves whichever model answered. read out of a real 59MB transcript, the distinct values were claude-opus-5, <synthetic>, claude-sonnet-5, claude-haiku-4-5-20251001, claude-fable-5 — note the synthetic marker and the inconsistent date suffix. so the transcript cannot answer "what should the picker show", and a new table does: chat_session_models (session_id pk, model, updated_at) officer's own id space, so opus[1m] and opusplan round-trip. keyed on the TRANSCRIPT uuid rather than officer's session id, because that is what the session list joins on — which is why the write waits for the harness to announce the id rather than firing at turn start. records the EFFECTIVE model, so a refused switch stores what answered. not derived from chat_session_events, which does now hold model:switched: right data, wrong index. that table holds every assistant message and is unbounded, and "latest model for these 200 sessions" would scan far more than the question deserves. not a chat_sessions table either — a session IS its transcript, and a row holding title and cwd would be a second source of truth for two fields that already have one. BACKFILL RUNS FROM postdb:push, so every server picks it up on its next schema push. bun does fire pre/post hooks for colon-named scripts — verified on 1.3.14 before building on it, because a post hook that silently never runs is the worst outcome here. insert-only, exits 0 on every failure path (the push has already applied; a cosmetic backfill must not make it look failed). seeded 11 of 12 transcripts on this machine. it reads the FIRST model, which sounds wrong for a table storing the current one — but switching only became possible today, so no older transcript can hold two, and first costs a bounded head read instead of reading 59MB. also: the family map returns null for mythos rather than a neighbouring family (Project Glasswing only — a picker entry that 401s is worse than none), and ModelSelector no longer picks a default once a conversation has started. an unset picker is the honest state when the server cannot say. tests: the alias map against the five real transcript values, and the selector's default gated on hasStarted. re-broke the guard to confirm it bites. still to do: `model` on the session LIST rows, which the sessions-list revamp wants. the storage and the batched read (getChatSessionModels) are in place for it. |
||
|
|
4c24df93c1 |
a chat can change model mid-conversation
the selector locked on `hasStarted`, so the tier was fixed for the life of a conversation. nothing justified it — the commit that added the flag says only "default model" — and the hazard it resembles is narrower than what it blocked: crossing HARNESS hands an opencode `ses_…` id to `claude --resume`, which reads as a dead agent rather than a wrong dispatch. changing TIER was always safe. so tier is free, harness is pinned, and the server refuses a crossing rather than relying on the UI to. the claude side does it the way the CLI's /model does: `Query.setModel()`, a control request that needs streaming input mode, which is the only mode this file uses. nothing is torn down and nothing is re-resumed — cwd, permission mode and transcript all continue and only the next response changes. an earlier plan here was to kill the session and re-create it with the stored resume id; that would have worked and was unnecessary. opencode already switched per turn (serve-runner POSTs the model to the live session), so only the UI was in its way. FIXES A SILENT MISMATCH while it is here. model reached the SDK once, in createSession's query() options, and every later turn was pushed onto that same call — so a changed model was dropped while session:init, the cost fold and the durable log all reported the new tier. reachable today by leaving a chat idle past the 30-minute GC: the UI said sonnet, opus answered, nothing logged it. the fold now tracks what the harness actually did rather than what the client asked for, and a refused switch reverts the picker. the switch is a durable `model:switched` event and a centred marker in the transcript, because the useful question is the BOUNDARY — which half of this conversation is opus. per-message model was already stamped server-side and shown nowhere, and claude-sessions.ts reads the FIRST model in a transcript, so a switch was invisible in history. tests: turn-stream proves the marker is durable and lands after the previous model's last words; the selector tests pull in opposite directions, so unlocking everything fails one and restoring the blanket lock fails the other. verified by re-breaking both. not done: opencode's `steer` path (a send mid-turn) returns before the model POST, so a switch there applies on the next full turn. session.model also stays stale for opencode tier changes — harness routing is unaffected. |
||
|
|
690a726f55 |
a member's turn ran as the owner after any idle gap
This is the cause. Everything before it was symptom.
`handleChat` had two spawn call sites. The first, for a new session, passed `member`.
The second — "session already live" — was a separate object literal that did not.
That branch is taken whenever OFFICER still holds the session, and officer holds it
long after the SIDECAR has let go: the sidecar reaps a session after 30 minutes idle,
`_claudeKill` stays set here regardless. So a gap sent a turn with no identity to a
sidecar with nothing to push it onto, and it created a fresh session from those
params. No member means the owner.
[claude:spawn:…] resume=49527b33… cwd=…/data/jg@pertento.ai/home as=owner
green is a Developer with their own Linux account, their own `claude` and their own
login. Every turn after a break ran as the OWNER: owner's HOME, owner's Anthropic
credential, owner's MCP config — which carries a JWT that signs as the owner. The
entire per-member boundary, undone by an omitted line in the branch nobody reads.
It surfaced as breakage rather than as silent escalation only by luck. The owner's
`claude` was handed the member's cwd — a 700 home it cannot enter — and the member's
transcript id, so it exited 1 instead of quietly working. Reported as "he went for a
walk and it stopped working", which is the idle timeout to the minute.
── The fix, and what it then found ──
Both paths now build from one `turnParams`, so they cannot drift again.
Then `member` was made REQUIRED-though-possibly-undefined in the protocol, and the
compiler immediately found three more sites that had left it out:
- `agent-handoff/deliver.ts` — the same bug, worse. No member AND a cwd from
`getOwnerHomeDir`, which ignores the email it is given. A member's agent panel ran
as the owner in the owner's home. Now resolves identity like chat does.
- `agents/agent-runner.ts`, `tasks/pipeline-executor.ts` — correct as they were:
both are `kind: 'execution'`, owner-only and never grantable. They now say
`member: undefined` explicitly, because a field you must fill in is a decision and
a field you may omit is an accident.
`resolveTurnIdentity` is exported so "whose turn is this" has one implementation
rather than three.
── What I got wrong on the way ──
Three wrong answers first — the credential, the working directory, then a recovery
whose condition could never fire — each from reading code and reasoning instead of
making the system say what it was doing. The log line that found this took five
minutes to write and should have been the first thing I did, not the fourth.
Also: `--resume` by hand appeared to work twice, which is what sent me down the wrong
paths. It proved nothing. The id I tested was not the id the platform passed.
tsgo clean, 950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b7d26a3350 |
a restarted background job survives a refresh
Reported as "I refreshed the page and the pill was gone, but the server is still
running and now I have no way to control it". Not a design choice — a bug, and it
came in with the restart button.
The tray is not stored anywhere. It is a fold over the durable chat event log:
`task:started` minus `task:notification`, per `collectRunningTasks`. That is what
rebuilds it after a refresh.
The restart route announced its respawn with `emitToSession`, which folds into the
in-memory session and writes to open sockets — and never called `appendChatEvent`.
So the pill existed only in the RAM of the browser tab that clicked the button. The
log still read:
seq 10979 task:started "Run dev server"
seq 11362 task:notification failed <- the ORIGINAL dying
Folded on refresh that says "not running", so the chip vanished — while the process
it described was up and serving on 3001. And every task route takes an id, so the
job became unreachable from the UI at the moment the browser forgot the id. Stop,
Pause and Restart would all still have worked; nothing could name the task.
The second half was quieter: `emitToSession` returned false when the session was not
in memory, and the route ignored the return. A platform restart drops the in-memory
session while the job keeps running, so the announcement went nowhere AND was never
written down.
Now it appends first and unconditionally — persistence does not need a listener,
only delivery does — and sends with the seq the log assigned, so a client's replay
cursor stays correct. Both events: the `task:started` is awaited so it is durable
before the client is told the restart worked, and the exit notification is
fire-and-forget because it lands long after the response.
This makes officer a writer of `chat_session_events`, which the note in websocket.ts
said it no longer was. `cut-off` had already been that exception. The real rule is
narrower and is now written down: officer writes events it ORIGINATES, because no
sidecar will. Turn output still belongs to the harness that produced it.
── How it was verified ──
End-to-end, out of band: a synthetic task with a real process holding a real output
file, a transcript in the harness's shape, and the two durable events that reproduce
the reported log. Driven through `buildHonoApp` so the permission gate and auth
middleware were in the path — the last two bugs of this kind both passed tests that
mounted the router alone.
BEFORE restart: 2 durable events -> tray would show 0 running (bug reproduced)
POST /restart -> 200
AFTER restart : 3 durable events -> tray would show 1 running
Not added to `bun test`: nothing in the suite touches Postgres, and this claim is
untestable without it. So this is a probe that ran once, not coverage.
Needs a server restart. tsgo clean, 919 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
92bac490d7 |
attach a PDF and the agent reads it; the microphone button is gone
Two requests, one file apart. ── The microphone ── Removed from the chat composer. The HOOK stays: `useAudioRecording` and `blobToWav` are also the file browser's DictateDialog, which is not what was asked to go. ── PDFs, by not extracting anything ── A PDF entry sat in the attach menu once with no `onSelect` at all — the menu offered it and did nothing, because nothing here could extract PDF text. It is back and real, and it still extracts nothing: the file is uploaded, left on disk, and the agent is given its PATH. Its own `Read` handles PDFs by page, better than any extraction this could have shipped, and it can go back for a section later without paying for it twice. The alternative was base64 in the message. That is megabytes in the turn, re-sent for the rest of the conversation, to produce worse text. ── What this uncovered ── `attachmentIds` has been on this websocket and in `types.ts` for a long time and NOTHING on the server read it. Images work because they travel separately as base64, so the ids were dead on the wire — and a PDF is exactly the case that needs them. They resolve to paths now, appended to the prompt as `Attached files (read from disk)`. Ids arrive in a websocket message and become a filesystem path, so they are checked rather than trusted: only the `<uuid>.<ext>` shape uploads actually generate, and only in the two directories uploads actually write to. Verified — a valid id resolves, an unknown one yields nothing, and `../../../../etc/passwd` and `; rm -rf /` yield nothing. Both upload directories are searched because the first message of a chat carries ids written to `tmp/` (no session yet) and later ones carry ids under the session. PDFs may be 25 MB against an image's 5: nothing carries them into a prompt, so the ceiling is storage rather than context. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. `[open]` For a MEMBER the file lands under `data/<email>/attachments/`, and whether their Linux user can read it depends on the confinement ACLs. Untested — the owner path is what was verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
3f071c0b24 |
the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and VAULT_STORE_KEY are no longer written by the setup script. data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/, capabilities/ and dockers/ as fixed names under it. The direction used to run the other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in app-store/paths.ts — which meant three environment variables that had to agree with each other and with the tree on disk. Eight files re-read process.env.DATA_PATH independently, each with its own `?? cwd()/data` fallback. They import the one value now, which is what made removing it safe: otherwise each would have derived its own and drifted. Three things this turned up. The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a comment asserting "__dirname is the repo root — this file sits beside ecosystem.config.cjs", which stopped being true when these files moved into ecosystem-files/. It walks up to the platform's package.json now, which holds wherever the file lives. That was a live bug before this change and a load-bearing one after it, since cwd now decides where the install is. assertInstallLayout joins the other two boot assertions. A wrong cwd does not error — it computes a plausible root somewhere else and writes managed homes and agent runs into it, so the install looks empty and the data looks lost with nothing naming the cause. It throws before serve(), first of the three, because a wrong answer there makes the other two check the wrong files. getOwnerHomeDir captures homedir() once at module load rather than per call. Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME when set rather than reading passwd, and user-instance.ts assigns process.env.HOME on its way to spawning an agent. A lazy read would have returned the owner's home on the first call and a member's afterwards. data-path.ts imports only node builtins, so it is evaluated before any of that runs. JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script does not boot — jwt.ts throws at module load without one. That is the agreed sequencing: they move to the SQLite store (docs/secret-store.md), and writing them here meanwhile would create a second origin for a secret the store then has to be reconciled with. Said plainly in .env.example and in lib/env.sh rather than left to be discovered. Not typechecked: node_modules is empty here and installs are frozen. Every edited file parses under `bun build --no-bundle`; the profile loads and pins the right cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx is not the pinned resolution and reformatted unrelated unions and line wraps in six files, so those were reverted and the edits re-applied by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ec1997fd0e |
a chat with no chosen directory runs in the caller's own home
The default was DATA_PATH/<email>/general_chat_sessions, a dedicated directory so /chat sessions formed their own Claude project group instead of cluttering the home. It is a sibling of the home, and confineUserTree makes every sibling the platform's at 0700 because the others are attachments and email_accounts. So it was unreachable for a member: the first live member turn started there and every Bash call failed on its own working directory before doing anything. A per-member copy inside each home fixed the symptom and left two rules to remember. The owner chose one rule instead — the account's own home, whoever they are — and accepted the trade knowingly: /chat sessions now share a project group with anything else run from that home, which was the reason the dedicated directory existed. Removed rather than left dangling: getGeneralChatSessionsCwd, ensureGeneralChatSessionsCwd, ensureMemberChatCwd, and general_chat_sessions from USER_DIRS so new accounts stop getting it. Existing directories are untouched and their transcripts stay where they are — Claude groups by cwd, so the owner's old /chat history remains under its own project slug rather than moving. The UI labels move with it: the default group now reads "home" rather than naming a directory that no longer has a role. ChatIdentity keeps carrying both email and home. The pairing was justified in the comment by general_chat_sessions being email-derived, which is now gone — but the distinction it encodes is real (the email says who, the home says where), so the comment explains that instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c84c74c91 |
give a member a chat cwd they can actually enter
host captured the first live member turn: uid 1001, nine env vars, zero ANTHROPIC_*, zero POSTGRES_URL, zero JWT_SECRET. The privilege drop and the allowlist both held. One defect. The default chat cwd was DATA_PATH/<email>/general_chat_sessions — a sibling of the member's home, which confineUserTree deliberately makes the platform's at 0700 because the other siblings are attachments and email_accounts. So the turn ran in a directory the member cannot enter, and every Bash call failed on its own cwd. The agent reported its shell as broken, which was true. A member's default is now ~member/general_chat_sessions, created as them through runAs. mkdir -p, so it is idempotent per turn and needs no reprovision. The owner's path does not change, and the sibling stays 0700 — loosening it would trade a broken shell for an open directory holding attachments and mail. 29 said this path is email-derived and therefore stays email-derived. True, and it did not follow that it is usable: an email-derived path under DATA_PATH is precisely the set a member is locked out of. Splitting identity from filesystem path was right; assuming the identity side was inert was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c59df4f866 |
let members use chat
The owner authorized this explicitly. Two refusals removed together, because
they were always one guard in two places: the wholesale isSuperAdmin middleware
in api/chat/chat.ts, and the chat socket's 403 in server.tsx.
They were right for the day they stood. A turn spawned claude as the OWNER and
every transcript path resolved through the owner's home, so a granted member
would have read the owner's sessions and run an agent as them.
What replaced them, rather than what deleted them:
the turn runs as the member spawnClaudeAsMember through sudo setpriv,
proven against a real account by reading file
ownership rather than trusting the process
the credential is theirs --reset-env plus an allowlist, so the owner's
proxy variables cannot cross
the transcripts are theirs ChatIdentity carries a home from resolveHomeDir
and claude-sessions cannot invent one
the sessions are theirs every session records its owner and all six
sidecar commands refuse a mismatch
Also adds the precondition host asked for in 10: a member whose claude is not
signed in gets the instruction rather than a turn that dies on an auth error and
reads as a broken agent. Not installed and not signed in are separate messages
because they need different actions.
registry.ts and registry.test.ts now describe chat as confined in fact rather
than ahead of its implementation. The comments at both former guards say what
had to exist first, and that a revert should go back to a refusal rather than to
a narrower one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1575df3f78 |
stop building cwds out of an email address
host found a live regression from |
||
|
|
95951fbe5a |
resolve transcripts and cwd against the caller's home, not the owner's
The history layer, and the last change that could be made without a live member.
claude-sessions.ts had `claudeHome = process.env.HOME_DIR ?? join(DATA_PATH,
email, 'home')`, which discards its argument whenever HOME_DIR is set — always,
on a real install. Every transcript read therefore resolved to the OWNER'S
~/.claude no matter who asked, and the comment above it asserted "single-user
platform" as though that were a property rather than an assumption. A member
reaching these functions would have been handed the owner's conversation list.
Now every read takes a ChatIdentity {email, home} with the home resolved from
resolveHomeDir(userId), and this file has no way to invent one. Both fields
travel together because they are genuinely different: general_chat_sessions
lives under DATA_PATH/<email>, not under a home. Collapsing them would be the
same class of mistake as undefined meaning "the owner".
websocket.ts's resolveCwd takes a home, so `~` expands against the caller's own.
Identity is resolved BEFORE the cwd — expanding `~` before knowing whose home it
is would be exactly the bug being removed — which also let a duplicate
resolveTurnIdentity call from
|
||
|
|
6aeb304f56 |
never spell "I don't know whose turn this is" as "the owner"
host caught that resolveMemberRun failed open. Returning undefined means "run as the server owner" downstream — their binary, their ~/.claude credential, their HOME, their MCP config carrying OFFICER_AUTH_TOKEN — and three different inputs produced it: the caller being the owner, resolveHomeDir failing, and a member whose osUser is null. The last two mean "could not determine", and answering them with the owner's identity is the single thing this feature exists to prevent. 23's own comment said the caller must not fall back to the owner. The code did exactly that. The prose was right. Now a discriminated TurnIdentity: owner, member, or refuse-with-a-reason. The call site ends the turn on refuse instead of spawning. The owner's identity is reachable only by positively establishing isOwner, never by failing to establish anything else — resolveHomeDir already reported it as a positive fact and the funnel through undefined was the only thing discarding it. The null-osUser case is not hypothetical: provisionOsAccount is non-fatal at every stage and records the account either way, as its own source says. Tonight provisioning failed three separate ways on a real member and the account survived each time. No test yet, and the reason is in COMMS rather than hidden: it needs database fakes this repo has no pattern for, and inventing one at 01:00 to cover four branches is how the next defect gets written. The union is exhaustive, so tsgo catches a missing case — not the same thing, not nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
311b2ea55c |
populate member from the authenticated socket
The last mechanical link: chat socket -> resolveMemberRun(userId) -> ClaudeSpawnStreamingParams.member -> claude-manager's branch -> spawnClaudeAsMember -> sudo setpriv. The path from a request to a privilege drop is now complete. Resolved from the authenticated socket, never from the client message — the same rule server.tsx applies to the pty sidecar, where it deletes any client-supplied osUser/home from the query string before setting its own. resolveMemberRun returns undefined rather than throwing when a home cannot be resolved, because undefined means "the owner" downstream: an account with no Linux user has nothing to confine a turn to, and falling back to the owner is the one wrong answer that must not happen by accident. A separate function with that reasoning attached rather than an inline ternary. Still inert. Both gates refuse non-owners before this line is reached, so the only path that reaches it today returns undefined via isOwner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d59adbf1f2 |
scope the six sessionKey commands to their caller
The control surface half of
|
||
|
|
d9857eef7c |
re-land: deliver a chat turn to every socket watching it, not the newest one
This is |
||
|
|
0a4ff548b9 |
revert the chat tabs and panes work, back to one conversation
Andre asked for zero, not another fix on top. Reverts ec4f06a..7726c9f — the ten commits from "tabs and panes" onward: the tab bar and pane splitting, tab renaming and its page title, the per-server directory picker, the render-loop fix, pane transcript resolution, the send queue, the two socket fixes from the other session, the pane-socket notes, and my own socket-set change from tonight. He is rebuilding from here. Deliberately KEPT: |
||
|
|
7726c9fc71 |
let every client watching a chat receive it, not the newest one
Reported from two devices at once: typing on the iPad, reading the reply on the Mac. Sending from the Mac produced nothing there. Both halves are one field. A session held `ws`, a single socket, and `attachWs` assigned it. So the newest attach silently took the turn away from whoever was already watching — and with a tab now holding up to three panes, plus a phone and a laptop on the same conversation, several sockets per session stopped being exotic and became the ordinary case. Now a Set, and every message goes to all of them. `detachWs(sessionId)` was worse, because it named no socket: it nulled the field on ANY close. A stale client going away therefore killed delivery for the client that had attached after it, which is the "nothing happens on the Mac" half. It takes the socket now and removes only that one, and the idle GC is armed only once nothing is left watching — otherwise a close would collect a session another pane is still reading. endTurnIfAgentIsGone takes the whole set for the same reason: a cut-off notice explains a spinner that will otherwise never stop, and telling one of three clients leaves two spinning. Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing identically on master before this change. Nobody has clicked it; the two devices that reported it are the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b07cae142f |
adopt on a resume even when the harness is a guess
Regression from my own B7 change, reported within the hour: turns collapsing to "turn completed without output" and coming back only on refresh. B7 stopped resume-cursor defaulting an unidentified session to claude-code. Correct for the durable cut-off row, wrong for adoption: useChat sends model only if modelRef.current is set, so a reconnect without one is routine, not exotic. Declining to adopt left the socket unbound to the live session, so the running turn output went nowhere — and a refresh looked like a fix because it rebuilds from the durable log. Adoption is about DELIVERY and must be generous; only the durable write needs certainty. So adopt on the default again, mark it as an assumption, and skip the cut-off check on it. That keeps B7 fixed — no false "agent went away" written against an opencode session — with no unbound sockets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
73b8111216 |
send images to opencode, which never needed the fork
B4 properly. The composer gate was the honest stopgap; this is the fix. opencode run takes attachments with --file, so images work on the subprocess path we already use — the parity doc had them down as phase 4, behind the serve migration, and they were not. The bug was one omission: handleOpenCodeChat`s msg type had no images field, so the browser sent them, the bubble rendered them, and they stopped at that signature. Nothing reported a loss anywhere. Attachments are paths, not inline data, so the sidecar spills each image to a temp file for the length of the turn and removes it in settle — the same place every other per-turn resource is released, so a killed or superseded turn cleans up too. The load-bearing detail is `--` before the prompt: --file is an array option, so without the separator the prompt is eaten as another filename and the turn dies with "File not found:" followed by the entire message. Confirmed against the binary, and pinned by a test that records argv from a stub. list-models now reports each model own capability instead of a hardcoded false — opencode publishes capabilities.input.image per model and nothing had ever read it. Defaults to false, so a model that does not declare it keeps the affordance hidden. Verified end to end: a red png sent over the chat socket to opencode/claude-sonnet-4-6 came back "Red". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d9a3513cb5 |
stop resume-cursor guessing that a session is claude
B7. `msg.model || DEFAULT_MODEL` declared every session without an explicit model to be claude-code, and the parity doc recorded only the visible half of what that cost. The durable false cut-off is real: endTurnIfAgentIsGone asked the claude sidecar about a key it had never held, was told false, and wrote "the agent went away" into a turn that was running fine. It survives reload, because surviving reload is what that row is for. The same default also handed the session to adoptOrphanedSession as a claude one, which subscribes it to that sidecar bus and pins session.model — so an opencode turn output never arrived, and stopping it called killClaude on a key that sidecar never had. A stop button that silently does nothing. decideResume makes both rules explicit: the server record beats the client claim, and an unknown harness stays unknown — no adoption, no cut-off check, just the replay. Silence is the safe failure when the wrong answer is written durably. DEFAULT_MODEL stays in handleAttach and is now commented as to why: that path reached its sessionId by asking the claude sidecar to resolve a claudeSessionId, so only claude could have answered. First test in api/chat, which had none. websocket.ts has no seam to drive the handler through, so the decision is extracted and tested; the wiring around it is not covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
bffae5ef61 |
log how many background tasks an attach recovered
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |