Step 2 of docs/push-notifications.md. One real channel working end to end before any Apple
or Google credential exists, so the pipe is proven before the hard part.
officer-notify is a PM2 peer with its own loopback listener, announced as notify:server and
proxied at /api/notify. It is a sidecar rather than platform code because the producers are
spread across sidecars — the queue, email, the agent — and a platform-owned notifier would
force every one of them to call back into the platform. That is the inversion just removed
from email; this avoids recreating it.
Channels sit behind one interface (types.ts) so APNs and FCM slot in beside Discord rather
than replacing anything. Each is awaited with its own error boundary and the dispatcher
always resolves: a job that finished has finished whether or not a banner appeared, so a
channel must never be able to break its producer.
text.ts is where the doorbell rule is actually enforced. APNs and FCM both need a title to
render a banner, so "send nothing" was never available — what we control is that the string
is composed HERE from the category alone. A producer sends { type: 'mail', count: 3 } and
the wire carries "3 new emails". It cannot carry a subject line because there is nowhere to
put one.
Device registration lives behind X-Officer-User, trusted because the listener binds loopback.
Platform and environment are validated rather than defaulted: an iOS token from a debug build
fails against production APNs with a silent BadDeviceToken, so a wrong value is a device that
never receives anything and never says why. GET /_officer/devices returns only the last 8
characters of a token — enough to identify a row, not enough to push to it.
Verified end to end against a fake webhook: /_health reports configured channels, a test
notification arrives as {"content":"Officer"}, { type: 'mail', count: 3 } arrives as
{"content":"3 new emails"}, and every validation path returns its own error.
Deletes src/servers/notify/discord.ts, which this supersedes and which had no other callers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
7.2 KiB
TypeScript
162 lines
7.2 KiB
TypeScript
import type { MessageCost, TurnMessage } from '../api/chat/types';
|
|
|
|
// ── Envelope ──
|
|
|
|
export type SidecarMessage = SidecarCommand | SidecarEvent;
|
|
|
|
// ── Commands (API server → sidecar) ──
|
|
|
|
export type SidecarCommand =
|
|
| { type: 'ping'; id: string }
|
|
| { type: 'state:sync'; id: string }
|
|
// Proxy
|
|
| { type: 'proxy:secret'; id: string }
|
|
// Claude Code
|
|
| { type: 'claude:spawn'; id: string; params: ClaudeSpawnParams }
|
|
| { type: 'claude:spawn-streaming'; id: string; params: ClaudeSpawnStreamingParams }
|
|
| { type: 'claude:kill'; id: string; sessionKey: string }
|
|
| { type: 'claude:interrupt'; id: string; sessionKey: string }
|
|
| { type: 'claude:clear-session'; id: string; sessionKey: string }
|
|
// OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir)
|
|
| { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams }
|
|
| { type: 'opencode:kill'; id: string; sessionKey: string }
|
|
// VNC
|
|
| { type: 'vnc:start'; id: string; params: VncStartParams }
|
|
// Provision the VNC password without starting a server — the UI needs it before it can connect
|
|
| { type: 'vnc:ensure-password'; id: string; email: string }
|
|
| { type: 'vnc:stop'; id: string; email: string }
|
|
| { type: 'vnc:status'; id: string; email: string };
|
|
|
|
// ── Responses/Events (sidecar → API server) ──
|
|
|
|
export type SidecarEvent =
|
|
| { type: 'pong'; id: string }
|
|
| { type: 'state:sync'; id: string; state: ClaudeState }
|
|
| { type: 'proxy:secret'; id: string; secret: string }
|
|
// Claude Code
|
|
| { type: 'claude:spawned'; id: string; sessionKey: string }
|
|
// A finished, browser-facing turn message. The agent has already committed it to chat_session_events
|
|
// and `seq` is its cursor id there; officer relays it verbatim. No `seq` means it is not durable —
|
|
// an `assistant:delta` (superseded by the text that follows) or a message whose write failed.
|
|
| { type: 'claude:message'; sessionKey: string; msg: TurnMessage; seq?: number }
|
|
| { type: 'claude:result'; id: string; result: ClaudeCodeResult }
|
|
| { type: 'claude:error'; id: string; error: string }
|
|
| { type: 'claude:killed'; id: string }
|
|
| { type: 'claude:interrupted'; id: string }
|
|
| { type: 'claude:session-cleared'; id: string }
|
|
// VNC
|
|
| { type: 'vnc:started'; id: string; port: number; display: number }
|
|
| { type: 'vnc:password'; id: string; password: string }
|
|
| { type: 'vnc:stopped'; id: string }
|
|
| { type: 'vnc:status'; id: string; session: VncSessionInfo | null }
|
|
| { type: 'vnc:error'; id: string; error: string }
|
|
// Email — new mail no longer crosses this socket; the sidecar owns the SSE stream and pushes directly
|
|
// OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect
|
|
| { type: 'opencode:server'; port: number }
|
|
// OpenCode turn streaming (analog of claude:*): spawned ack, per-message stream, session id report
|
|
| { type: 'opencode:spawned'; id: string; sessionKey: string }
|
|
// Same contract as `claude:message`: a finished turn message the sidecar has already committed to
|
|
// chat_session_events, plus the cursor id it landed on. Officer relays it and folds it into its
|
|
// in-memory transcript; it does not translate or persist.
|
|
| { type: 'opencode:message'; sessionKey: string; msg: TurnMessage; seq?: number }
|
|
| { type: 'opencode:session'; sessionKey: string; sessionId: string }
|
|
| { type: 'opencode:error'; id: string; error: string }
|
|
// Music — the sidecar reports where its audio-streaming HTTP server is listening (random port) on connect
|
|
| { type: 'music:server'; port: number }
|
|
// Vault — the sidecar reports where its Vaultwarden reverse-proxy HTTP/WS server is listening on connect
|
|
| { type: 'vault:server'; port: number }
|
|
// slskd — the sidecar reports where its slskd reverse-proxy HTTP server is listening (random port) on connect
|
|
| { type: 'slskd:server'; port: number }
|
|
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'headscale:server'; port: number }
|
|
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'transmission:server'; port: number }
|
|
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'invoiceshelf:server'; port: number }
|
|
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'wallet:server'; port: number }
|
|
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect
|
|
| { type: 'pty:server'; port: number }
|
|
// Email — the sidecar reports where its mail HTTP server is listening (random port) on connect
|
|
| { type: 'email:server'; port: number }
|
|
// Notify — the sidecar reports where its notification HTTP server is listening (random port) on connect
|
|
| { type: 'notify:server'; port: number }
|
|
// Generic
|
|
| { type: 'error'; id?: string; error: string };
|
|
|
|
// ── Claude sidecar state ──
|
|
|
|
export type ClaudeState = {
|
|
proxySecret: string;
|
|
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
|
|
};
|
|
|
|
// ── Param types ──
|
|
|
|
export type ClaudeSpawnParams = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
prompt: string;
|
|
sessionKey: string;
|
|
model?: string;
|
|
cwd?: string;
|
|
};
|
|
|
|
export type ClaudeSpawnStreamingParams = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
prompt: string;
|
|
sessionKey: string;
|
|
cwd?: string;
|
|
model?: string;
|
|
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
|
|
// Whether turn output should be committed to chat_session_events (default true). A chat session wants
|
|
// it — that is what survives an officer restart. A pipeline step does not: its sessionKey is a throwaway
|
|
// uuid no browser will ever replay, and the job's own event log is its record.
|
|
durable?: boolean;
|
|
};
|
|
|
|
export type ClaudeCodeResult = {
|
|
text: string;
|
|
sessionId: string;
|
|
model: string;
|
|
cost: MessageCost;
|
|
};
|
|
|
|
// ── OpenCode turn params ──
|
|
|
|
export type OpenCodeRunParams = {
|
|
sessionKey: string;
|
|
prompt: string;
|
|
cwd?: string; // passed to `opencode run --dir` — hard-re-anchors tools to this directory
|
|
model?: string; // `providerID/modelID` (e.g. opencode/claude-haiku-4-5); passed to --model verbatim
|
|
resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`)
|
|
durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams
|
|
};
|
|
|
|
// ── VNC types ──
|
|
|
|
export type VncStartParams = {
|
|
email: string;
|
|
username: string;
|
|
resolution?: string;
|
|
};
|
|
|
|
export type VncSessionInfo = {
|
|
email: string;
|
|
display: number;
|
|
port: number;
|
|
pid: number;
|
|
alive: boolean;
|
|
};
|
|
|
|
// ── PTY ──
|
|
//
|
|
// Nothing but a port crosses this socket now. The pty sidecar serves its own HTTP + WebSocket listener and
|
|
// the browser reaches it through a byte relay, so there is no command vocabulary left: pty:init, :input,
|
|
// :resize, :close and :list all lived here until the sidecar owned its own transport, and officer filtered
|
|
// one global output stream per session to feed them. The port arrives as the shared `pty:server` event
|
|
// that createSidecarProxy already listens for.
|