Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 7e054ddf11 deploy report 2026.08.22.01 — 6 of 6 hosts, officer-dev skipped
Rolled deploy/2026.08.22.01 to alpha, fofos, macbookpro, edge-pertento-bridge,
production-pertento-bridge and deadpool-rezio-bridge. Every host fast-forwarded
98177c3b -> 78db9c8c, ran plugins:update, restarted officer, and came back online.

No bun install and no db:push, as the plan required.

officer-dev skipped as the commander's own host, and already on this code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:30:15 +00:00
pastilhasandClaude Opus 5 0c2ff88c88 a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat
rather than navigating to Command HQ — right, and it cost the composer its commander controls, because
`isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander,
rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a
watcher wants, and the one the dialog makes default inside a commander.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:29:35 +00:00
pastilhasandClaude Opus 5 c982353381 the boot check now reads the sockets that are served, not a list beside them
assertPermissionTotality exists to refuse a surface nobody has gated, and it was reading the wrong list:
Object.keys(handlers) — the map of implementations — while Bun served a hand-written route table twenty
lines away. The comment beside it said neither could be a stale copy of the other. That was false, and it
had a live instance: /api/cliamp/ws and /api/cliamp/audio/ws stayed in the route table for two days after
their handlers and registry claims were commented out. Not exploitable — isWsProviderAllowed finds no
permission and 403s a member, while the owner upgrades onto a dead socket — but precisely the drift the
guard is for, invisible to the guard.

Now there is one declaration. WS_ROUTES maps path → provider, the route table is generated from it, and the
check reads it. A socket route cannot exist without being checked.

Two smaller guards arrived with it. A route naming a provider with no handler refuses to boot, with the path
in the message, instead of 500-ing on first connect. And the provider union that was inline in upgradeWs is
named, so the route table typechecks against what that function can actually serve — which caught my first
attempt, where WS_ROUTES was Record<string, string>.

sidecar and dev-proxy are listed explicitly as served-but-absent-from-WS_ROUTES, because they upgrade
through their own handlers rather than upgradeWs. Named so the absence cannot read as an oversight.

Half the TODO was stale and it is recorded as such rather than quietly closed: the eight red tests it cites
have been green since cliamp was deleted on 2026-08-15. Deleting cliamp removed the symptom and left the
shape, which is the more interesting half.

Verified against the real prefix lists rather than by reasoning: the served set passes, one unclaimed
provider is refused by name, all five generated routes answer 401, and the server boots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:25:48 +00:00
11 changed files with 291 additions and 52 deletions
+32 -32
View File
@@ -20,39 +20,39 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes ## Themes
**87 open, 22 closed**, across 29 themes. **85 open, 24 closed**, across 29 themes.
| Theme | Open | Closed | | Theme | Open | Closed |
| ------------------------------------------------------------------------------------------ | ---- | ------ | | --- | --- | --- |
| [Affordances](todo/affordances/) | 1 | 0 | | [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 | | [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 | | [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 0 | | [Background jobs](todo/background-jobs/) | 2 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 | | [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 | | [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 | | [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 | | [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 | | [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 | | [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 6 | 0 | | [Infra (alpha)](todo/infra/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 4 | 1 | | [Known bugs](todo/known-bugs/) | 3 | 2 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 | | [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 18 | 3 | | [Multi-user](todo/multi-user/) | 17 | 4 |
| [Notifications](todo/notifications/) | 1 | 0 | | [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 | | [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 | | [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 | | [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 | | [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 | | [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 | | [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 | | [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 | | [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 | | [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 | | [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 0 | | [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 | | [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 | | [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 | | [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
## Records — no items, kept for the reasoning ## Records — no items, kept for the reasoning
+104
View File
@@ -0,0 +1,104 @@
# 2026.08.22.01 — deploy report
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
No host failed, no process came back `errored`.
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
## Per host
### `alpha` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
`~/dockers/officer.dev/platform` was not touched).
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
photos, soulseek, transmission, vault.
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
- All 19 processes `online` afterwards.
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
captured an explicit `0`.
### `fofos` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
touch it, and it was left alone. No tracked change.
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
fix of 2026-08-21 is holding.
### `macbookpro` — succeeded
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
interactive shell, exactly as the plan and `docs/servers.md` require.
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
### `edge-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
### `production-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
and the command still exited 0 — recorded so the next reader does not chase it.
- `pm2 restart officer`: 9 → 10, `online`.
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
All `online`, none touched.
### `deadpool-rezio-bridge` — succeeded
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
the others'. Not an error.
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
### `officer-dev` — SKIPPED, and current
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
another agent working in this same tree during the rollout. Its work was left alone; only this report was
staged, by path.
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
at `78db9c8c`, the tag — but the next release will include them.
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
## Verification
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
## What was not done
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
happy with the rest. It needs no pull.
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
files arrived, not that each parses.
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
+67 -16
View File
@@ -162,16 +162,60 @@ const handlers: Record<string, any> = {
sidecar: sidecarWebsocket, sidecar: sidecarWebsocket,
}; };
// Both doors into the platform, checked against the permission registry before either opens. /**
// * Every websocket path Bun serves, and the provider each one upgrades to.
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated *
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half * ── Why this exists rather than a hand-written route table ──
// from the map directly above, so neither list can be a stale copy of the thing it describes. *
* The boot check below used to be fed `Object.keys(handlers)` — the map of provider implementations — while
* Bun served a SEPARATE, hand-listed set of routes. Two lists describing one thing, and the comment beside
* the check said neither could be a stale copy of the other, which was simply not true: a socket route with
* no handler, or with no registry claim, was invisible to the very check that exists to refuse it.
*
* It had a live instance. `/api/cliamp/ws` and `/api/cliamp/audio/ws` stayed in the route table for two
* days after their handlers and registry entries were commented out — not exploitable, since
* `isWsProviderAllowed` finds no permission and 403s a member while the owner upgrades onto a dead socket,
* but exactly the drift the guard is for. Deleting cliamp on 2026-08-15 removed the symptom and left the
* shape.
*
* Now the routes are GENERATED from this, and this is what totality reads. A path added here without a
* handler or without a registry claim fails the boot rather than serving quietly.
*/
const WS_ROUTES: Record<string, UpgradableProvider> = {
'/api/tasks/run/ws': 'task-runner',
'/api/tasks/pipeline/ws': 'pipeline',
'/api/terminal/ws': 'terminal',
'/api/chat/ws': 'chat',
'/api/chat/notify/ws': 'chat-notify',
};
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
/*
Both doors into the platform, checked against the permission registry before either opens.
This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated cannot
be reached even once. The HTTP half comes from hono.ts's own mount table. The socket half now comes from
WS_ROUTES, which the route table is BUILT from, so the check reads what is actually served rather than a
parallel list that agrees with it by hand.
A provider with a handler and no route is fine and is not checked: unreachable is the safe direction. The
failure this catches is the other one — a route reachable with nothing claiming it.
*/
assertPermissionTotality({ assertPermissionTotality({
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES], apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
wsProviders: Object.keys(handlers), wsProviders: SERVED_WS_PROVIDERS,
}); });
// A route cannot name a provider that has no implementation. Cheap, and it turns a 500 on first connect
// into a refusal to boot with the name in it.
for (const [path, provider] of Object.entries(WS_ROUTES)) {
if (!handlers[provider]) throw new Error(`${path} upgrades to '${provider}', which has no handler`);
}
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question; // Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a // it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
// module whose only job is a side effect is one an import sweep quietly deletes. // module whose only job is a side effect is one an import sweep quietly deletes.
@@ -312,11 +356,16 @@ console.log(
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used', : '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
); );
async function upgradeWs( /**
req: Request, * A provider reachable by upgrading a URL.
server: any, *
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline', * Named rather than inline so `WS_ROUTES` is checked against it: a route naming a provider this function
) { * cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
* deliberately absent — they upgrade through their own handlers, not through here.
*/
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
const token = new URL(req.url).searchParams.get('token'); const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 }); if (!token) return new Response('Unauthorized', { status: 401 });
@@ -441,11 +490,13 @@ const server = serve({
}); });
if (!ok) return new Response('Upgrade failed', { status: 500 }); if (!ok) return new Response('Upgrade failed', { status: 500 });
}, },
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'), // Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'), ...Object.fromEntries(
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'), Object.entries(WS_ROUTES).map(([path, provider]) => [
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'), path,
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'), (req: Request, server: any) => upgradeWs(req, server, provider),
]),
),
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe // CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any // fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them // credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
+10
View File
@@ -257,6 +257,16 @@ chatRouter.get('/sessions/:id', async (ctx) => {
offset: windowed ? start : 0, offset: windowed ? start : 0,
title: context?.title ?? null, title: context?.title ?? null,
partCount: context?.partCount ?? 1, partCount: context?.partCount ?? 1,
/*
Whether this conversation is a commander, derived from ITS OWN cwd rather than from the screen asking.
The composer offers "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and
decided whether to by looking at the route it was rendered on. So the same commander, opened from
/chat instead of Command HQ, silently lost the control that is the whole point of it. A commander is a
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
travels with the conversation.
*/
isCommander: isCommandersDir(who.email, detail.cwd),
}); });
}); });
@@ -19,6 +19,8 @@ type ResumeState = {
messages: ChatMessage[]; messages: ChatMessage[];
total: number; total: number;
offset: number; offset: number;
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
isCommander: boolean;
}; };
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in // Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
@@ -104,6 +106,9 @@ const ChatPanelInner = ({
promptPrefix={promptPrefix} promptPrefix={promptPrefix}
panelId={panelId} panelId={panelId}
sessionId={sessionKey ?? undefined} sessionId={sessionKey ?? undefined}
// From the SESSION, so a commander opened here gets a commander's composer. `chatContext` may also
// set it via `context`; either being true is enough — see `manager.isCommander`.
isCommander={resume?.isCommander === true}
{...chatContext} {...chatContext}
/> />
</div> </div>
@@ -221,6 +226,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
messages: detail.messages as unknown as ChatMessage[], messages: detail.messages as unknown as ChatMessage[],
total: detail.total, total: detail.total,
offset: detail.offset, offset: detail.offset,
isCommander: detail.isCommander === true,
}); });
} catch { } catch {
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather // A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
@@ -23,6 +23,14 @@ type EmbeddableChatProps = {
chat?: UseChatType; chat?: UseChatType;
context?: string; context?: string;
contextId?: string; contextId?: string;
/**
* This SESSION is a commander, as reported by `GET /chat/sessions/:id`.
*
* Separate from `context`, which says which SCREEN is rendering. Both can make the composer offer the
* handoff watcher and only this one is true of the conversation wherever it is opened — which is the bug:
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
*/
isCommander?: boolean;
onMessageComplete?: () => void; onMessageComplete?: () => void;
}; };
@@ -49,6 +49,8 @@ type UseEmbeddableChatParams = {
chat?: UseChatType; chat?: UseChatType;
context?: string; context?: string;
contextId?: string; contextId?: string;
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
isCommander?: boolean;
}; };
/** /**
@@ -611,6 +613,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
// place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the // place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the
// composer, so the option was never rendered anywhere at all. // composer, so the option was never rendered anywhere at all.
context: params.context, context: params.context,
/*
One answer to "is this a commander", from either of the two things that can know.
The SCREEN knows when Command HQ starts one, before the session exists and before any route could
report on it. The SESSION knows once it has a transcript, wherever it is opened. Neither alone is
enough: screen-only was the original bug (a commander in /chat lost its controls) and session-only
would lose the brand-new composer, which has no session yet.
*/
isCommander: params.context === 'command-hq' || params.isCommander === true,
messages, messages,
historicalCount, historicalCount,
streamingText, streamingText,
@@ -199,12 +199,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
`handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a `handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a
promise the platform has to keep rather than only a sentence in the prompt. promise the platform has to keep rather than only a sentence in the prompt.
Read from the MANAGER rather than recomputed from the route, which is what it was: a commander
opened from /chat reported `context: 'chat'` and lost the option, having gained it two hours
earlier. `manager.isCommander` is true when either the screen or the session says so.
This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the
option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the
default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the
server. Only the radio button was missing, so the feature looked unbuilt. server. Only the radio button was missing, so the feature looked unbuilt.
*/ */
isCommander={manager.context === 'command-hq'} isCommander={manager.isCommander}
onSubmit={(intent, preamble, armWatcher) => { onSubmit={(intent, preamble, armWatcher) => {
// Claimed by the next job to start, which is how the preview tells this one apart from the // Claimed by the next job to start, which is how the preview tells this one apart from the
// background jobs an agent starts on its own. // background jobs an agent starts on its own.
@@ -96,6 +96,14 @@ export type ClaudeSessionDetail = {
title?: string | null; title?: string | null;
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */ /** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
partCount?: number; partCount?: number;
/**
* A commander rather than an ordinary conversation, derived server-side from this session's own cwd.
*
* It travels with the SESSION and not with the screen, which is the whole point. The composer decides
* whether to offer "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and used
* to decide it from the route, so the same commander opened in /chat quietly lost it.
*/
isCommander?: boolean;
}; };
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean }; export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
@@ -2,7 +2,7 @@
id: known-bugs/a-commander-opened-from-chat-loses-its-context id: known-bugs/a-commander-opened-from-chat-loses-its-context
title: 'A commander opened from /chat loses its commander controls' title: 'A commander opened from /chat loses its commander controls'
theme: known-bugs theme: known-bugs
status: open status: done
created: 2026-08-21 created: 2026-08-21
date_source: stated date_source: stated
order: 4 order: 4
@@ -28,3 +28,16 @@ closes this and makes the same thing work in any future surface that renders a c
Related: the same `context` name means two different things either side of `EmbeddableChat` — a `useChat` Related: the same `context` name means two different things either side of `EmbeddableChat` — a `useChat`
option and a component prop, neither reading the other's — which is what hid the original bug twice. option and a component prop, neither reading the other's — which is what hid the original bug twice.
---
**Fixed 2026-08-22.** `GET /chat/sessions/:id` now reports `isCommander`, derived from the session's own cwd
by the same `isCommandersDir` the list and `/chat/live` already used — so the answer travels with the
conversation instead of with the screen showing it.
The composer reads ONE value, `manager.isCommander`, true when either source says so. Both are needed and
neither is sufficient: the SCREEN knows when Command HQ starts a commander, before a session or a transcript
exists for any route to report on, and the SESSION knows once it does, wherever it is opened. Screen-only
was this bug; session-only would have lost the brand-new composer.
Verified on a real commander: `172cdd8c` resolves `cwd=…/commanders` and `isCommander=true`.
@@ -1,8 +1,8 @@
--- ---
id: multi-user/assertpermissiontotality-checks-the-wrong id: multi-user/assertpermissiontotality-checks-the-wrong
title: "`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13" title: '`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13'
theme: multi-user theme: multi-user
status: open status: done
created: 2026-08-13 created: 2026-08-13
date_source: stated date_source: stated
order: 12 order: 12
@@ -17,3 +17,27 @@ a dead socket. But the boot check that exists to stop exactly this cannot see it
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served, tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
which is why nobody noticed. Found 2026-08-14. which is why nobody noticed. Found 2026-08-14.
---
**Fixed 2026-08-22.** Half of this item was already stale and half was still exactly true, which is worth
separating because the stale half is what would have made someone close it.
Stale: the eight red tests in `registry.test.ts` are green, and have been since cliamp was deleted on
2026-08-15 — that removed the live instance of the drift along with the routes.
Still true: the two lists were still two lists. `assertPermissionTotality` read `Object.keys(handlers)`, the
map of implementations, while Bun served a hand-written route table twenty lines away. The comment beside
the check claimed neither could be a stale copy of the other, and that was simply false.
The fix is one declaration. `WS_ROUTES` maps path → provider, the Bun route table is GENERATED from it, and
the boot check reads it — so a socket route cannot exist without being checked. Two smaller guards came with
it: a route naming a provider with no handler now refuses to boot with the path in the message, and the
provider union that was inline in `upgradeWs` is named (`UpgradableProvider`) so the route table typechecks
against what that function can actually serve.
`sidecar` and `dev-proxy` are listed explicitly as served-but-not-in-WS_ROUTES, because they upgrade through
their own handlers. Named rather than omitted, so their absence cannot read as an oversight.
Verified by running the check against the real prefix lists: the served set passes, and adding one unclaimed
provider is refused with `websocket provider 'cliamp-audio' is served but no permission claims it`.