run the agent as a pm2 peer instead of a child of officer

the process that runs claude (sidecar/claude/user-instance.ts) had no pm2 entry
and was spawned on demand by the main server, with stdout/stderr inherited. that
made every agent session a grandchild of officer, so pm2's tree-kill took the
session down on every `pm2 restart officer` — the single thing that makes it
impossible to work on the platform while an agent is running.

give it its own entry (officer-agent) and delete the spawn machinery:
ensureClaudeSidecar, spawnAndWaitForRegistration, the 50ms registration poll and
the per-email claudeProcs/claudeSpawnWaiters maps, ~77 lines. officer now spawns
no sidecar at all.

for that to work the sidecar had to stop needing officer to start:

- it resolves the owner from the database (getOwnerUser) instead of reading
  CLAUDE_USER_EMAIL out of the env officer built. single-user is a hard
  invariant, so there is nothing to fan out over. CLAUDE_USER_EMAIL still wins
  when set, for manual runs, and a fresh install waits for bootstrap rather
  than exiting into a restart loop.
- it reads the anthropic proxy secret from the proxy sidecar's own state file
  rather than being handed it in env. lazily, because ensureProxySecret
  persists on a 30s debounce and pm2 starts both processes together.

it registers as 'agent' with capability 'claude', so the registry finds it the
way it finds every other sidecar. that removes the email argument from
killClaude, interruptClaude and clearClaudeSession, which only ever existed to
locate a per-email sidecar by name.

what officer keeps is a short wait-for-capability, because pm2 brings peers up
together and the first request after a boot can beat the sidecar's registration.

also align the two officer port fallbacks in the sidecar (5000 for the socket,
9010 for the rest base) — same instance, so they cannot disagree.

this fixes R1 and R2 from CLAUDE_SIDECAR_ISOLATION.md. events produced while
officer is down are still lost; that is stage 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 04:34:01 +00:00
co-authored by Claude Opus 4.8
parent 86930f5b17
commit 62dc4c1a5c
9 changed files with 340 additions and 122 deletions
+207
View File
@@ -0,0 +1,207 @@
# Frontend Navigation Audit — the "opaque click" anti-pattern
**Date:** 2026-07-30 · **Branch:** `sidecars-claude` · **Status:** exploration + documentation only (no code changed)
Prep work for the upcoming **full navigation refactor**. This catalogues every place the frontend
navigates to / selects an **addressable entity** using a `<button onClick>` / `<div onClick>` /
imperative `navigate()` / global-channel setter **instead of a real `<Link to>` / `<a href>`**.
> Line numbers were captured on `sidecars-claude` at audit time and may drift as other work lands —
> treat them as anchors, re-grep the symbol if a line looks off.
---
## The anti-pattern (definition)
A clickable element selects/opens something that has (or should have) a URL, but:
- **(a)** the entity id/slug is **not in the DOM** (no `href`, no `data-*`) — it lives only in an onClick closure;
- **(b)** clicking **doesn't change the URL** (or does so only via an indirect state→URL effect);
- **(c)** selection is held in **JS state / a global channel** (`usePanelChannel`, `useGlobal`), not the URL;
- **(d)** **no anchor semantics** — can't cmd/ctrl-click to a new tab, no middle-click, not link-focusable.
**Exemplar (already fixed):** the `/chat` session list. Rows were `<button onClick={() => selectById(id)}>`
(id only in the closure) → converted to `<Link to={`/chat/${session.id}`}>` (committed to master `f35c145`).
That fix is the template for the HIGH items below. **Caveat:** the fix only did the *rows* — the chat
**detail panel** still selects via channel, not the URL (finding **C1**), so `/chat` is the model for both
"done right" (rows) and "still to do" (detail).
## Route map (from `App.tsx`)
**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/projects/:id`, `/email/:emailId`.
**Flat screens:** `/` `/files` `/music` `/soulseek` `/tasks` `/skills` `/processes` `/activity`
`/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/browser` `/code-editor` `/settings/{profile,ai,system,integrations,apps}`.
**What's already correct** (lean on these in the refactor): the **Dock**, **Header** (logo + mobile sheet),
**UserMenu**, **JobsIndicator** are all real `<Link>`s. Shared `NavLink.tsx` (query-string-appending `<Link>`
wrapper — note: *not* react-router's NavLink, gives no active state) and `BackButton.tsx` (`<Link>` back arrow)
are good building blocks. The **Workspace/Panel framework** contains **zero** route navigation — it's orthogonal.
---
## Consolidated findings — severity ranked
### 🔴 HIGH — addressable route already exists; just needs a `<Link>` / URL-as-source-of-truth
| ID | file:line | Entity | Current impl | Fix |
|----|-----------|--------|--------------|-----|
| H1 | `Screens/Dashboard/Jobs/JobsPage.tsx:108` | a job | `<button onClick={() => navigate(`/jobs/${job.id}`)}>` — id in closure | → `<Link to={`/jobs/${job.id}`}>`. Active-row already keys off `useParams().id`; keep the stop/delete button. **The exact twin of the /chat fix.** |
| H2 | `workspaces/…/apps/Dashboards/DashboardListApp.tsx:133` | a dashboard | `<div onClick={handleClick}>``useGlobal(SELECTED_DASHBOARD_KEY)` on-page (**no URL change**), `navigate()` off-page | rows → `<Link to={`/dashboards/${ws.id}`}>`; drop the global as selection source (derive from `useParams`). Header is already a `<Link>` — app is internally inconsistent. |
| H3 | `workspaces/…/apps/Projects/ProjectListApp.tsx:161` | a project | `<div onClick={handleClick}>``useGlobal(SELECTED_PROJECT)` on-page (**no URL change**), `navigate()` off-page | identical to H2 → `<Link to={`/projects/${p.id}`}>`; retire `SELECTED_PROJECT` as source of truth. |
| H4 | `workspaces/…/apps/ChatHistory/ChatDetailPanel.tsx:136` | which chat to render | reads `usePanelChannel('chat:selected-session')`, **not** `useParams` | make the route the source of truth: read `sessionId` from `useParams`, fetch by id, retire `chat:selected-session` (or make it a derived cache). **Finishes the /chat fix.** |
| H5 | `Screens/Dashboard/Email/EmailList.tsx:336` | an email | `<button data-email-id onClick={setSelectedId}>` + `EMAIL_SELECTED` channel + a state↔URL sync effect (`EmailScreen.tsx:27-33`) | rows → `<Link to={`/email/${msg.id}`}>`, drive selection **from** `useParams().emailId`, delete the two sync effects. (Already round-trips the URL + has `data-email-id`, but still isn't an anchor.) |
> **Verify:** `Dashboards/DashboardPreview.tsx:355,377` and `Projects/ProjectPreview.tsx:430,457` also
> `navigate('/…/:id')`. The nav auditor read these as HIGH "open entity"; the apps auditor read the Project
> ones as *correct* post-create/edit flows. Eyeball them during H2/H3 — convert the ones that are "open an
> existing entity from a preview", leave genuine post-mutation redirects.
### 🟠 MEDIUM — navigable entity with **no route yet** (add a route, then link)
| ID | file:line | Entity | Proposed route | Note |
|----|-----------|--------|----------------|------|
| M1 | `Screens/Dashboard/CapabilityPage.tsx:431` | task / skill / process | `/tasks/:dir`, `/skills/:dir`, `/processes/:dir` | one component backs **three** screens (Tasks/Skills/Processes). Highest-value MEDIUM. Also drops the auto-select-`items[0]` effect. |
| M2 | `Screens/Dashboard/TaskLogs/index.tsx:104` | a task-log run | `/task-logs/:id` | detail fetch already keys off the id — clean move. |
| M3 | `Screens/Dashboard/Activity/ActivityScreen.tsx:63,73` | background task / detached job | `/activity/:id` | two row types; unify under one param, screen re-derives `task=`/`path=`. |
| M4 | `FileBrowser/.../useFileBrowserApp.ts:269`, `FileItem.tsx:516`, `Breadcrumb.tsx:16`, search-hit `:249` | a folder | `/files?path=<dir>` | **files** already open via `?view=`; **folders** are pure `currentPath` state — no URL, no back/forward. Folder rows + crumbs → `<Link>` on a `?path=` param. |
| M5 | `CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33` | open source file / active tab | `/code-editor?file=<path>` (+ `open=` for tabs) | tree file rows → `<Link>`; active file in a param. |
| M6 | `Layout/Settings/SettingsPanel.tsx:39` | a settings sub-section | `/settings/:page/:section` or `?section=` | `<button onClick={setSelectedKey}>``useGlobal`; nothing in URL, not deep-linkable. **One change covers all settings pages** (all go through `createSettingsPanelComponents`). |
| M7 | `workspaces/components/Combobox.tsx:53` | caller-supplied route | (existing) | shared widget: `href`-bearing options do `navigate(option.href)`; render them as `<Link>`. Every caller inherits the opaque click. |
| M8 | `Layout/Header/UserMenu.tsx:51` | — | — | **dead target:** `<Link to="/settings/resources">` — route doesn't exist (falls through to `/`). Add the route or remove the item. |
| M9 | `Screens/Dashboard/Plans/index.tsx:36` | a plan document | `/plans/:name` | native `<select>` → local state; a plan is a real addressable doc. Have the select `navigate()` or use a link list. |
| M10 | `SystemMonitor/ScopeList.tsx:14` | monitor scope (btop/pm2/docker) | `/system-monitor/:scope` | small fixed set; low urgency but genuinely addressable. |
**Music** (M-music) and **Soulseek** (M-slsk) are whole-workspace channel apps — pulled out below because each
is **one design decision** that cascades across many files:
- **Music** — `Music/{MusicBrowser:182,209 · MusicDetail:138,174,234 · FavoritesView:85,107}` hold the entire
library location in `usePanelChannel('music:cwd')`; every drill-in is `setCwd(path)`. No URL, no deep-link to
an album. → back `music:cwd` with `/music?path=<rel>`; rows become `<Link>`s.
- **Soulseek** — an entire workspace with **no URL at all**: `soulseek:section` nav (`SoulseekNav:40`,
`SoulseekDashboard:88/96/110/121`), `soulseek:user` peer channel (`UserMenu:32`, `SoulseekUsers:42`), and
per-panel `useState` for past-search (`SearchView:160`, a textbook `role="button"` div with `s.id` in the
closure), rooms (`SoulseekRooms:153`), conversations (`SoulseekChat:135`). → decide a nested tree
`/soulseek/:section` + `/soulseek/{users/:name, search/:id, rooms/:name, chat/:name}`. **S1 (the section nav)
decides S2S7.**
### 🟡 LOW / borderline
- **Dock / Header active styling** (`Dock.tsx:43,78` · `Header.tsx:53,112`) — destinations are already real
`<Link>`s; only the *active* class is JS-derived from `useLocation`. Switch to react-router `<NavLink>` and drop the hand-rolled `isActive`.
- **Browser tabs** (`Browser/TabList.tsx:93`) — ephemeral CDP targets (polled, come/go every few seconds); a durable `/browser/:targetId` is dubious. Probably leave as selection.
- **Jobs step/iteration** (`Jobs/JobDetail.tsx:234,255`) — intra-detail sub-selection; only if we want `/jobs/:id/:stepKey` deep links.
- **Preview slug** (`Preview/PreviewApp.tsx:23`) — Preview usually derives its target from the surrounding dashboard/project `cwd`; the manual picker is a fallback. Probably leave URL-less.
- **FileBrowser widget** (`FileBrowserWidget/.../BrowseTab.tsx:36`) — compact sidebar widget; keeping its browse cursor local is defensible.
- **Music favorites view** (`music:fav`) & **SystemMonitor scope** — view toggles over tiny fixed sets; channel-state is arguably fine.
- **"New Chat" button** (`ChatHistory/SessionList.tsx:68`) — a create-action that also `navigate('/chat/new')`; could be `<Link to="/chat/new">` if the state-set moves into the route. Its `chat:selected-session` write is part of the H4 cleanup.
---
## Channel-based selection map (the core of the refactor)
Every place an **addressable entity** is selected through a global channel / global state instead of the URL.
This is the primary surface to convert to URL-driven selection.
| Channel / global key | Entity held | Should map to | Files |
|----------------------|-------------|---------------|-------|
| `chat:selected-session` | open chat session | `/chat/:sessionId` | `ChatDetailPanel.tsx:136`, `SessionList.tsx:16` (H4) |
| `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` |
| `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) |
| `SELECTED_PROJECT` (`useGlobal`) | selected project | `/projects/:id` | `ProjectListApp.tsx:45`, `ProjectPreview.tsx:386` (H3) |
| `EMAIL_SELECTED` | open email | `/email/:emailId` | `EmailList.tsx:336`, `EmailScreen.tsx` sync (H5) |
| `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` |
| `soulseek:section` | active Soulseek section | `/soulseek/:section` | `SoulseekNav/View/Dashboard/UserMenu` |
| `soulseek:user` | selected peer | `/soulseek/users/:name` | `UserMenu.tsx:28`, `SoulseekUsers.tsx:34` |
| `monitor:scope` | monitor scope | `/system-monitor/:scope` | `ScopeList.tsx` (via `shared.ts:15`) |
| settings `useGlobal(*_SELECTED)` | settings section | `/settings/:page/:section` | `SettingsPanel.tsx:56` (M6) |
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` |
**Excluded — event-bus / refresh signals, NOT selection:** `preview:refresh`, `files:refresh-signal`,
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`, `FILE_VIEWER_CHANNEL`, `chat:active-session` (embedded-chat signal).
---
## Appendix A — all `navigate()` call sites (whole frontend, 25)
"Link?" = a `<Link>`/`<NavLink>` is the right refactor.
| # | file:line | what | target | Link? | note |
|---|-----------|------|--------|-------|------|
| 1 | `Jobs/JobsPage.tsx:109` | job list row | `/jobs/:id` | **YES** | H1 |
| 2 | `Dashboards/DashboardListApp.tsx:90` | dashboard row (off-page) | `/dashboards/:id` | YES | H2 |
| 3 | `Dashboards/DashboardListApp.tsx:114` | inside "New Dashboard" | `/dashboards` | ~ | create action |
| 4 | `Dashboards/DashboardPreview.tsx:355` | open dashboard | `/dashboards/:id` | YES | verify vs H2 |
| 5 | `Dashboards/DashboardPreview.tsx:377` | open dashboard | `/dashboards/:id` | YES | verify vs H2 |
| 6 | `Projects/ProjectListApp.tsx:116` | project row (off-page) | `/projects/:id` | YES | H3 |
| 7 | `Projects/ProjectListApp.tsx:142` | inside "New Project" | `/projects` | ~ | create action |
| 8 | `Projects/ProjectPreview.tsx:430` | open project | `/projects/:id` | YES | verify vs H3 |
| 9 | `Projects/ProjectPreview.tsx:457` | open project | `/projects/:id` | YES | verify vs H3 |
| 10 | `Jobs/JobDetail.tsx:709` | back button | `/jobs` | YES | use `BackButton`/`<Link>` |
| 11 | `ChatHistory/SessionList.tsx:70` | "New Chat" | `/chat/new` | ~ | borderline (LOW) |
| 12 | `components/Combobox.tsx:54` | nav combobox option | `option.href` | YES | M7 |
| 13 | `MusicPlayer/MusicPlayerHost.tsx:251` | open current album (setCwd+nav) | `/music` | ~ | has side-effect |
| 14 | `FileBrowserWidget/useFileBrowserWidget.ts:53` | open folder in Files | `/files?view=…` | ~ | query nav |
| 15 | `FileBrowserApp/useFileBrowserApp.ts:401` | create dashboard from folder | `/dashboards/new?…` | ~ | action-nav |
| 16 | `FileBrowserApp/useFileBrowserApp.ts:427` | create dashboard from files | `/dashboards/new?…` | ~ | action-nav |
| 17 | `FileBrowserApp/.../TaskRunnerModal.tsx:1168` | after starting job | `/jobs` `/jobs/:id` | NO | post-submit |
| 18 | `FileBrowserApp/.../TaskRunnerModal.tsx:1649` | after starting pipeline | `/jobs/:id` | NO | post-submit |
| 19 | `Email/EmailScreen.tsx:29` | sync selection → URL (effect) | `/email/:emailId` | NO | delete in H5 |
| 20 | `Email/EmailScreen.tsx:31` | clear selection → URL (effect) | `/email` | NO | delete in H5 |
| 21 | `ChatHistory/index.tsx:107` | redirect when no id | `/chat` | NO | guard |
| 22 | `Projects/ProjectListScreen.tsx:66` | clear/redirect | `/projects` | NO | guard |
| 23 | `Authentication/ForgotPassword/useResetPassword.ts:48` | post-reset redirect | `/` | NO | action result |
| 24 | `Authentication/LandingPage/Bootstrap.tsx:34` | post-bootstrap redirect | `/` | NO | action result |
| 25 | `hooks/useQueryState/useQueryState.ts:39` | generic query-state writer | dynamic | NO | infra hook |
---
## ✅ TO-DO — ordered for tomorrow
### Phase 1 — Quick wins (routes already exist; mechanical, high value)
- [ ] **H1** Jobs rows → `<Link to={`/jobs/${job.id}`}>` (`JobsPage.tsx:108`). The clean twin of the /chat fix.
- [ ] **H2** Dashboards rows → `<Link>`; retire `SELECTED_DASHBOARD_KEY` as selection source (`DashboardListApp.tsx:133`).
- [ ] **H3** Projects rows → `<Link>`; retire `SELECTED_PROJECT` (`ProjectListApp.tsx:161`).
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**.
- [ ] **H5** Email rows → `<Link>` driven by `useParams().emailId`; delete the state↔URL sync effects (`EmailList.tsx:336`, `EmailScreen.tsx:27-33`).
- [ ] **M8** Fix or remove the dead `/settings/resources` link (`UserMenu.tsx:51`).
- [ ] Verify + convert the 4 preview "open" navigates (`DashboardPreview:355,377`, `ProjectPreview:430,457`); leave genuine post-mutation redirects.
### Phase 2 — Add a route, then link (per-entity, medium effort)
- [ ] **M6** Settings sub-sections → nested route or `?section=`, render `SectionButton` as `<NavLink>` (`SettingsPanel.tsx:39`) — one change covers all settings pages.
- [ ] **M1** Capabilities → `/tasks|skills|processes/:dir`, rows → `<Link>` (`CapabilityPage.tsx:431`) — covers 3 screens.
- [ ] **M2** TaskLogs → `/task-logs/:id` (`TaskLogs/index.tsx:104`).
- [ ] **M3** Activity → `/activity/:id` (`ActivityScreen.tsx:63,73`).
- [ ] **M4** FileBrowser folders → `/files?path=`; folder rows + breadcrumbs → `<Link>` (`useFileBrowserApp.ts:269`, `FileItem.tsx`, `Breadcrumb.tsx`).
- [ ] **M5** CodeEditor active file → `/code-editor?file=` (`FileTree.tsx:59`, `EditorTabs.tsx:33`).
- [ ] **M7** Combobox: render `href` options as `<Link>` (`Combobox.tsx:53`).
### Phase 3 — Whole-workspace routing decisions (needs a design call first)
- [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `<Link>` (M-music).
- [ ] **Soulseek** — decide the nested tree `/soulseek/:section` + `/soulseek/{users,search,rooms,chat}/:x`; **S1 decides S2S7** (M-slsk).
- [ ] **M10** SystemMonitor scope → `/system-monitor/:scope` (`ScopeList.tsx:14`).
- [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`).
### Phase 4 — Polish + borderline decisions
- [ ] Dock + Header + mobile sheet → react-router `<NavLink>` for active state; drop hand-rolled `isActive` (`Dock.tsx`, `Header.tsx`).
- [ ] "New Chat" → `<Link to="/chat/new">` (`SessionList.tsx:68`) once H4's channel cleanup lands.
- [ ] Jobs back button + any `navigate('/jobs')` → shared `BackButton` (`JobDetail.tsx:709`).
- [ ] Decide/skip: Browser tabs, Jobs step deep-link, Preview slug, FileBrowser widget, Music-favorites/Monitor-scope view toggles.
### Cross-cutting for the refactor itself
- [ ] Standardise a URL-as-source-of-truth pattern for panel selection (replace the `usePanelChannel`/`useGlobal`
selection channels in the map above with `useParams`/`useSearchParams`, keeping channels only for
genuine signals/refresh buses).
- [ ] Adopt `<NavLink>` (real react-router) for all nav chrome so active state stops being JS-derived.
- [ ] Keep `NavLink.tsx` (query-string wrapper) and `BackButton.tsx` as the standard building blocks.
---
## Coverage
- **Area 1 — Dashboard screens** (`Screens/Dashboard/**`, excl. Settings/Layout): 9 findings (2 HIGH, 4 MEDIUM, 3 LOW) + spillover to `officerdev` apps.
- **Area 2 — Workspace panel apps** (`workspaces/officerdev/src/apps/**`): 22 findings (4 HIGH, ~15 MEDIUM, 3 LOW); 11 distinct channel-selection sites.
- **Area 3 — Global nav / Layout / Settings / shared components**: 6 findings (0 HIGH — chrome is already links; 3 MEDIUM, 3 LOW) + the 25-site `navigate()` map.
Raw per-area findings (with full impl / id-in-DOM / URL-change / coverage notes) were produced in the
audit run; this document is the consolidated, de-duplicated synthesis. The three HIGH clusters
(Jobs · Dashboards · Projects) plus the two chat halves (rows done, detail = H4) are the direct siblings of
the `/chat` fix and the highest-value starting point.
+10
View File
@@ -15,6 +15,16 @@ module.exports = {
args: 'run src/servers/sidecar/claude/index.ts',
watch: false,
},
// The process that actually runs `claude`. It used to be spawned on demand by the main server,
// which made every agent session a grandchild of `officer` and killed it on every restart. As a PM2
// peer it survives them. It resolves the owner from the database and the proxy secret from the
// proxy's state file, so it needs nothing from `officer` in order to start.
{
name: 'officer-agent',
script: 'bun',
args: 'run src/servers/sidecar/claude/user-instance.ts',
watch: false,
},
{
name: 'officer-opencode',
script: 'bun',
+1
View File
@@ -2,6 +2,7 @@ export {
getUsers,
getUserById,
getUserByEmail,
getOwnerUser,
getUserCount,
createUser,
updateUser,
@@ -19,6 +19,14 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
return user;
}
// Single-user platform: there is exactly one account, created once by POST /auth/bootstrap. Sidecars
// that need "who is the owner" (e.g. the agent sidecar, which PM2 starts with no email in its env)
// resolve it here rather than being told by the main server.
export async function getOwnerUser(): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).orderBy(users.id).limit(1);
return user;
}
export async function getUserCount(): Promise<number> {
const [result] = await db.select({ count: sql<number>`count(*)::int` }).from(users);
return result?.count ?? 0;
+1 -1
View File
@@ -572,7 +572,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
if (isClaudeModel(session.model)) {
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the
// warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill.
void sidecar.interruptClaude(sessionId, session.email);
void sidecar.interruptClaude(sessionId);
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
} else {
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
+3 -3
View File
@@ -18,8 +18,8 @@ type ClaudeCodeResult = {
cost: MessageCost;
};
export function clearClaudeCodeSession(sessionKey: string, email?: string): void {
sidecar.clearClaudeSession(sessionKey, email);
export function clearClaudeCodeSession(sessionKey: string): void {
sidecar.clearClaudeSession(sessionKey);
}
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
@@ -61,7 +61,7 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
return {
kill: () => {
sidecar.killClaude(params.sessionKey, params.email);
sidecar.killClaude(params.sessionKey);
unsub();
},
};
+29 -101
View File
@@ -1,6 +1,4 @@
import { resolve } from 'node:path';
import type { ServerWebSocket } from 'bun';
import type { Subprocess } from 'bun';
import type {
SidecarCommand,
SidecarEvent,
@@ -113,13 +111,6 @@ function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
return undefined;
}
function findSidecarByName(name: string): RegisteredSidecar | undefined {
for (const sc of sidecars.values()) {
if (sc.name === name) return sc;
}
return undefined;
}
// ── Event dispatch ──
function dispatchEvent(msg: SidecarEvent | PtyEvent) {
@@ -195,82 +186,27 @@ function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand | PtyComma
sc.ws.send(JSON.stringify(cmd));
}
// ── On-demand Claude sidecar spawning ──
// ── Waiting for a sidecar to appear ──
const USER_INSTANCE_SCRIPT = resolve(import.meta.dir, 'sidecar/claude/user-instance.ts');
const SIDECAR_SPAWN_TIMEOUT_MS = 15_000;
// Officer no longer spawns any sidecar; PM2 owns every one of them. The only thing left to handle is
// startup order — PM2 brings `officer` and its peers up together, so the first request after a boot can
// arrive a beat before the sidecar has finished dialling in. Wait briefly rather than failing the
// request. (This replaces ~77 lines of spawn-and-poll: `ensureClaudeSidecar`,
// `spawnAndWaitForRegistration`, and the per-email `claudeProcs`/`claudeSpawnWaiters` maps.)
const CAPABILITY_WAIT_MS = 15_000;
const CAPABILITY_POLL_MS = 100;
const claudeProcs = new Map<string, Subprocess>();
const claudeSpawnWaiters = new Map<string, Promise<RegisteredSidecar>>();
async function ensureClaudeSidecar(email: string): Promise<RegisteredSidecar> {
const name = `claude:${email}`;
// Already registered?
const existing = findSidecarByName(name);
async function waitForCapability(cap: string, timeoutMs = CAPABILITY_WAIT_MS): Promise<RegisteredSidecar> {
const existing = findSidecarByCapability(cap);
if (existing) return existing;
// Already spawning?
const waiter = claudeSpawnWaiters.get(email);
if (waiter) return waiter;
// Spawn and wait for registration
const promise = spawnAndWaitForRegistration(email, name);
claudeSpawnWaiters.set(email, promise);
try {
return await promise;
} finally {
claudeSpawnWaiters.delete(email);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await Bun.sleep(CAPABILITY_POLL_MS);
const sc = findSidecarByCapability(cap);
if (sc) return sc;
}
}
async function spawnAndWaitForRegistration(email: string, name: string): Promise<RegisteredSidecar> {
const proxySecret = await getProxySecret();
const proxyPort = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
const env: Record<string, string> = {
...(process.env as Record<string, string>),
CLAUDE_USER_EMAIL: email,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}`,
ANTHROPIC_API_KEY: proxySecret,
};
const proc = Bun.spawn(['bun', 'run', USER_INSTANCE_SCRIPT], {
env,
stdout: 'inherit',
stderr: 'inherit',
});
claudeProcs.set(email, proc);
// Clean up on exit
proc.exited.then(() => {
claudeProcs.delete(email);
});
// Wait for the sidecar to register
return new Promise<RegisteredSidecar>((resolve, reject) => {
const timeout = setTimeout(() => {
unsub();
reject(new Error(`Claude sidecar for ${email} failed to register within ${SIDECAR_SPAWN_TIMEOUT_MS}ms`));
}, SIDECAR_SPAWN_TIMEOUT_MS);
// Poll for registration (the sidecar connects via WebSocket and registerSidecar is called)
const check = () => {
const sc = findSidecarByName(name);
if (sc) {
clearTimeout(timeout);
clearInterval(interval);
resolve(sc);
}
};
const interval = setInterval(check, 50);
const unsub = () => {
clearTimeout(timeout);
clearInterval(interval);
};
});
throw new Error(`No sidecar with capability "${cap}" registered within ${timeoutMs}ms`);
}
// ── Public API ──
@@ -303,10 +239,14 @@ export function getProxySecretSync(): string {
return cachedState?.proxySecret ?? '';
}
// ── Claude Code (per-user routing) ──
// ── Claude Code (the `officer-agent` sidecar, capability 'claude') ──
// Single-user platform, so there is exactly one agent sidecar and it is found by capability like every
// other one. The `email` on the params is still passed through to the sidecar — it needs it to resolve
// paths — but officer no longer uses it to *locate* anything.
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
const sc = await ensureClaudeSidecar(params.email);
const sc = await waitForCapability('claude');
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
if (res.type === 'claude:result') return res.result;
if (res.type === 'claude:error') throw new Error(res.error);
@@ -314,36 +254,24 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
}
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
const sc = await ensureClaudeSidecar(params.email);
const sc = await waitForCapability('claude');
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
if (res.type === 'claude:spawned') return;
if (res.type === 'claude:error') throw new Error(res.error);
throw new Error('Unexpected response');
}
export function killClaude(sessionKey: string, email: string): void {
const sc = findSidecarByName(`claude:${email}`);
if (sc) sendFireToSidecar(sc, { type: 'claude:kill', id: nextId(), sessionKey });
export function killClaude(sessionKey: string): void {
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
}
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
export function interruptClaude(sessionKey: string, email: string): void {
const sc = findSidecarByName(`claude:${email}`);
if (sc) sendFireToSidecar(sc, { type: 'claude:interrupt', id: nextId(), sessionKey });
export function interruptClaude(sessionKey: string): void {
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey });
}
export function clearClaudeSession(sessionKey: string, email?: string): void {
if (email) {
const sc = findSidecarByName(`claude:${email}`);
if (sc) sendFireToSidecar(sc, { type: 'claude:clear-session', id: nextId(), sessionKey });
} else {
// Broadcast to all claude sidecars (used when email is not available)
for (const sc of sidecars.values()) {
if (sc.capabilities.includes('claude')) {
sendFireToSidecar(sc, { type: 'claude:clear-session', id: nextId(), sessionKey });
}
}
}
export function clearClaudeSession(sessionKey: string): void {
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
}
export function onClaudeEvent(handler: (sessionKey: string, event: ChatEvent) => void): () => void {
+25
View File
@@ -101,6 +101,31 @@ export async function flushAndSave(): Promise<void> {
await saveState();
}
/**
* Read the Anthropic proxy secret out of the *proxy* sidecar's state file.
*
* The proxy (`officer-anthropic-proxy`) and the agent (`officer-agent`) keep separate state — see
* `initPaths`: `DATA_PATH/sidecar/` versus `DATA_PATH/<email>/sidecar/` — so the agent cannot reach
* the secret through `getState()`. It used to be handed the secret in env by the main server, and
* needing that handoff is precisely why the agent had to be spawned by `officer` (and therefore died
* with it). Reading it off disk keeps the two processes independent, with the proxy still the only
* writer.
*
* Returns '' when the secret is not on disk yet: `ensureProxySecret` persists through a 30s debounce,
* so a brand-new install has a window where the file exists without it. Callers should treat '' as
* "retry later" rather than fatal.
*/
export function readProxySecretFromDisk(): string {
try {
const proxyStateFile = join(DATA_PATH, 'sidecar', 'claude-state.json');
if (!existsSync(proxyStateFile)) return '';
const parsed = JSON.parse(readFileSync(proxyStateFile, 'utf-8')) as Partial<PersistedState>;
return parsed.proxySecret ?? '';
} catch {
return '';
}
}
// ── Lockfile ──
export function acquireLock(): boolean {
+56 -17
View File
@@ -2,30 +2,39 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock } from './state';
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock, readProxySecretFromDisk } from './state';
import { setMcpConfigPath } from './claude-manager';
import * as claudeManager from './claude-manager';
import { createSidecarConnector } from '../connect';
import { sign } from '../../jwt';
import { getUserByEmail, getEmailAccounts } from 'officerdb';
import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb';
const email = process.env.CLAUDE_USER_EMAIL;
if (!email) {
console.error('[user-instance] CLAUDE_USER_EMAIL is required');
process.exit(1);
// PM2 starts this sidecar with no user in its env. Single-user platform, so resolve the owner from the
// database rather than being told who to run as by the main server — one less thing that has to come
// from `officer` before this process can work. CLAUDE_USER_EMAIL still wins when set, for manual runs.
async function resolveOwner() {
const explicit = process.env.CLAUDE_USER_EMAIL?.trim();
for (;;) {
const user = explicit ? await getUserByEmail(explicit) : await getOwnerUser();
if (user) return user;
// Fresh install: wait for POST /auth/bootstrap instead of exiting into a PM2 restart loop.
console.log(`[agent] no ${explicit ? `user "${explicit}"` : 'owner account'} yet — retrying in 5s`);
await Bun.sleep(5_000);
}
}
const dbUser = await resolveOwner();
const email = dbUser.email;
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${process.env.PORT ?? '9010'}`;
// Same officer instance for both, so the fallback port has to agree. It used to default to 5000 for the
// WebSocket and 9010 for the REST base, which would have split them apart if PORT were ever unset.
const OFFICER_PORT = process.env.PORT ?? '9010';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${OFFICER_PORT}`;
const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${OFFICER_PORT}`;
const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts');
// Mint a long-lived JWT for this user so tools (e.g. gmail) can call back to dev-platform as them
const dbUser = await getUserByEmail(email);
if (!dbUser) {
console.error(`[user-instance] no user found for ${email}`);
process.exit(1);
}
const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.username }, '30d');
// Single-user platform: the owner runs Claude with no isolation — real HOME, real ~/.claude — so
@@ -49,7 +58,7 @@ process.env.HOME = homeDir;
initPaths(email);
if (!acquireLock()) {
console.error(`[claude:${email}] another instance is already running (lock file exists with live PID)`);
console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`);
process.exit(1);
}
@@ -85,6 +94,30 @@ function generateMcpConfig(): string {
return join(contextDir, 'mcp-host.json');
}
// ── Anthropic credentials ──
// The `claude` CLI inherits this process's env (claude-manager spawns with `process.env`), so the proxy
// endpoint and secret have to be set here. Officer used to inject both when it spawned this process;
// reading them ourselves is what lets this sidecar be a PM2 peer instead of a child of the server.
//
// Resolved lazily rather than once at boot: PM2 starts the proxy and the agent together, and
// `ensureProxySecret` persists on a 30s debounce, so on a first-ever boot the secret can be briefly
// absent. Re-checked before every spawn until it lands.
const ANTHROPIC_PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
function ensureAnthropicEnv(): void {
process.env.ANTHROPIC_BASE_URL ??= `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`;
if (process.env.ANTHROPIC_API_KEY) return;
const secret = readProxySecretFromDisk();
if (secret) {
process.env.ANTHROPIC_API_KEY = secret;
console.log('[agent] anthropic proxy secret loaded from disk');
} else {
console.warn('[agent] anthropic proxy secret not on disk yet — retrying before next spawn');
}
}
// ── Startup ──
// The owner runs un-isolated with HOME as their real home, so the generated container CLAUDE.md is
@@ -92,8 +125,9 @@ function generateMcpConfig(): string {
// terminal `claude` loads too.
setMcpConfigPath(generateMcpConfig());
ensureAnthropicEnv();
console.log(`[claude:${email}] started (HOME=${homeDir})`);
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
// ── Command handlers ──
@@ -106,6 +140,7 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
break;
case 'claude:spawn': {
ensureAnthropicEnv();
try {
const result = await claudeManager.spawnClaude(cmd.params);
reply({ type: 'claude:result', id: cmd.id, result });
@@ -116,6 +151,7 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
}
case 'claude:spawn-streaming': {
ensureAnthropicEnv();
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
const onEvent = (event: import('../../api/chat/types').ChatEvent) => {
@@ -158,9 +194,12 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
// ── Connect to API server ──
// A stable identity, not a per-email one. Officer looks this sidecar up by the 'claude' capability, so
// it no longer needs to know which user is running to find it — that was the last thing tying the
// registry's claude verbs to an email argument.
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: `claude:${email}`,
name: 'agent',
capabilities: ['claude'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
@@ -170,7 +209,7 @@ const connection = createSidecarConnector({
// ── Graceful shutdown ──
async function shutdown(signal: string) {
console.log(`[claude:${email}] ${signal} received, saving state...`);
console.log(`[agent] ${signal} received, saving state...`);
connection.destroy();
await flushAndSave();
releaseLock();