// Single source of truth for the WebSocket frame Zod runtime schema.
// Validation runs on send (broker.publishFrame / publishUserFrame) and
// on receive (apps/web hooks useSessionStream + useUserEvents). Catches
// silent protocol drift between publisher and consumer.
//
// Per-kind payload schemas stay z.unknown() — frame-level drift detection
// is the goal; deep payload validation is follow-up work.

import { z } from 'zod';

const Uuid = z.string().uuid();
// Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs.
const ToolCallId = z.string().min(1);
// v1.13.12 fix: postgres returns timestamp columns as JS Date objects, not
// strings. The publish sites pass them through unchanged, so the schema must
// tolerate both. preprocess converts Date → ISO string before string-validation;
// on the web side (where frames arrive via JSON.parse) it's a no-op. Before
// this fix, every message_complete / session_updated / chat_updated frame
// failed validation and got dropped — symptoms: token tracking blank in UI,
// status stuck at 'streaming' tripping the 60s stale-stream banner.
const IsoTimestamp = z.preprocess(
  (v) => (v instanceof Date ? v.toISOString() : v),
  z.string().min(1),
);

const ChatStatusValue = z.enum([
  'streaming',
  'tool_running',
  'waiting_for_input',
  'idle',
  'error',
]);

// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
// dispatcher + permission flow on the per-session channel.
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);

const ErrorReasonValue = z.enum([
  'llm_provider_error',
  'doom_loop',
  'doom_loop_summary_failed',
  'cap_hit',
  'cap_hit_summary_failed',
]);

const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']);

const ToolCallShape = z.object({
  id: ToolCallId,
  name: z.string().min(1),
  args: z.record(z.string(), z.unknown()),
});

// Free-form bags: opaque to the frame schema; deep validation is out of
// scope for v1.13.11 (frame-level drift detection is the goal; per-kind
// payload narrowing is follow-up work). z.unknown() means the consumer
// must narrow before reading — TypeScript-side this is fine because every
// consumer already operates on the hand-maintained Project / Chat / Session
// / WorkspacePane types, and the Zod-typed shape is only used at the
// publishFrame boundary.
const OpaqueObject = z.unknown();

export const SnapshotFrame = z.object({
  type: z.literal('snapshot'),
  messages: z.array(OpaqueObject),
});

export const MessageStartedFrame = z.object({
  type: z.literal('message_started'),
  message_id: Uuid,
  chat_id: Uuid.optional(),
  role: MessageRoleValue,
  // v2.8-compare: groups messages belonging to the same compare operation.
  compare_group_id: z.string().uuid().optional(),
  stream_seq: z.number().int().min(0).optional(),
});

export const DeltaFrame = z.object({
  type: z.literal('delta'),
  message_id: Uuid,
  chat_id: Uuid.optional(),
  content: z.string(),
  compare_group_id: z.string().uuid().optional(),
  stream_seq: z.number().int().min(0).optional(),
});

export const ReasoningDeltaFrame = z.object({
  type: z.literal('reasoning_delta'),
  message_id: Uuid,
  chat_id: Uuid.optional(),
  content: z.string(),
  stream_seq: z.number().int().min(0).optional(),
});

export const ToolCallFrame = z.object({
  type: z.literal('tool_call'),
  message_id: Uuid,
  chat_id: Uuid.optional(),
  tool_call: ToolCallShape,
  stream_seq: z.number().int().min(0).optional(),
});

export const ToolResultFrame = z.object({
  type: z.literal('tool_result'),
  tool_message_id: Uuid,
  chat_id: Uuid.optional(),
  tool_call_id: ToolCallId,
  output: z.unknown(),
  truncated: z.boolean(),
  error: z.string().optional(),
  // v2.8: unified diff for write tools (edit_file, create_file, etc.).
  // Published alongside successful tool results so the frontend can render
  // a compact diff snippet inline. Absent for read-only tools or failures.
  diff: z.string().optional(),
  stream_seq: z.number().int().min(0).optional(),
});

export const MessageCompleteFrame = z.object({
  type: z.literal('message_complete'),
  message_id: Uuid,
  chat_id: Uuid.optional(),
  tokens_used: z.number().int().nonnegative().nullable().optional(),
  ctx_used: z.number().int().nonnegative().nullable().optional(),
  ctx_max: z.number().int().positive().nullable().optional(),
  cache_tokens: z.number().int().nonnegative().nullable().optional(),
  reasoning_tokens: z.number().int().nonnegative().nullable().optional(),
  started_at: IsoTimestamp.nullable().optional(),
  finished_at: IsoTimestamp.nullable().optional(),
  // nullable: external-coder turns carry task.model, which is null when no
  // model was selected. This frame is published through the same fail-closed
  // publishFrame, so null MUST validate or the entire frame (incl. the
  // status:'complete' transition) is dropped.
  model: z.string().nullable().optional(),
  metadata: OpaqueObject.nullable().optional(),
  // F1 (D-8): the terminal status of the assistant message. Absent on the native
  // BooChat path (reducer defaults to 'complete'); the BooCoder dispatcher stamps
  // it 'cancelled' on a user Stop / stall and 'failed' on a thrown error so the
  // web reducer can render a muted "Stopped" / failed state without a new frame
  // type. Optional → fail-closed publishFrame must keep, not strip, it.
  status: z.enum(['complete', 'cancelled', 'failed']).optional(),
  compare_group_id: z.string().uuid().optional(),
  stream_seq: z.number().int().min(0).optional(),
});

export const UsageFrame = z.object({
  type: z.literal('usage'),
  message_id: Uuid,
  chat_id: Uuid.optional(),
  completion_tokens: z.number().int().nonnegative().nullable(),
  ctx_used: z.number().int().nonnegative().nullable(),
  ctx_max: z.number().int().positive().nullable(),
  stream_seq: z.number().int().min(0).optional(),
});

export const MessagesDeletedFrame = z.object({
  type: z.literal('messages_deleted'),
  message_ids: z.array(Uuid),
  chat_id: Uuid.optional(),
  stream_seq: z.number().int().min(0).optional(),
});

export const ChatRenamedFrame = z.object({
  type: z.literal('chat_renamed'),
  chat_id: Uuid,
  name: z.string(),
});

export const CompactedFrame = z.object({
  type: z.literal('compacted'),
  session_id: Uuid,
  chat_id: Uuid,
  summary_message_id: Uuid,
});

export const ErrorFrame = z.object({
  type: z.literal('error'),
  message_id: Uuid.optional(),
  chat_id: Uuid.optional(),
  error: z.string(),
  reason: ErrorReasonValue.optional(),
  compare_group_id: z.string().uuid().optional(),
  stream_seq: z.number().int().min(0).optional(),
});

export const ChatStatusFrame = z.object({
  type: z.literal('chat_status'),
  chat_id: Uuid,
  status: ChatStatusValue,
  at: IsoTimestamp,
  reason: ErrorReasonValue.optional(),
});

export const SessionUpdatedFrame = z.object({
  type: z.literal('session_updated'),
  session_id: Uuid,
  project_id: Uuid,
  name: z.string(),
  updated_at: IsoTimestamp,
});

export const SessionRenamedFrame = z.object({
  type: z.literal('session_renamed'),
  session_id: Uuid,
  name: z.string(),
});

export const SessionCreatedFrame = z.object({
  type: z.literal('session_created'),
  session: OpaqueObject,
  project_id: Uuid,
});

export const SessionArchivedFrame = z.object({
  type: z.literal('session_archived'),
  session_id: Uuid,
  project_id: Uuid,
});

export const SessionDeletedFrame = z.object({
  type: z.literal('session_deleted'),
  session_id: Uuid,
  project_id: Uuid,
});

export const SessionWorkspaceUpdatedFrame = z.object({
  type: z.literal('session_workspace_updated'),
  session_id: Uuid,
  // v2.6.x: widened from z.array — the payload is now either the legacy bare
  // WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
  // nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
  // every envelope frame at validation.
  workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
});

export const ChatCreatedFrame = z.object({
  type: z.literal('chat_created'),
  chat: OpaqueObject,
  session_id: Uuid,
});

export const ChatUpdatedFrame = z.object({
  type: z.literal('chat_updated'),
  chat_id: Uuid,
  session_id: Uuid,
  name: z.string().nullable(),
  updated_at: IsoTimestamp,
});

export const ChatArchivedFrame = z.object({
  type: z.literal('chat_archived'),
  chat_id: Uuid,
  session_id: Uuid,
});

export const ChatUnarchivedFrame = z.object({
  type: z.literal('chat_unarchived'),
  chat: OpaqueObject,
});

export const ChatDeletedFrame = z.object({
  type: z.literal('chat_deleted'),
  chat_id: Uuid,
  session_id: Uuid,
});

export const ProjectCreatedFrame = z.object({
  type: z.literal('project_created'),
  project: OpaqueObject,
});

export const ProjectArchivedFrame = z.object({
  type: z.literal('project_archived'),
  project_id: Uuid,
});

export const ProjectUnarchivedFrame = z.object({
  type: z.literal('project_unarchived'),
  project: OpaqueObject,
});

export const ProjectUpdatedFrame = z.object({
  type: z.literal('project_updated'),
  project_id: Uuid,
  name: z.string(),
});

export const ProjectDeletedFrame = z.object({
  type: z.literal('project_deleted'),
  project_id: Uuid,
});

const PermissionOptionShape = z.object({
  option_id: z.string(),
  label: z.string(),
});

export const PermissionRequestedFrame = z.object({
  type: z.literal('permission_requested'),
  task_id: Uuid,
  session_id: Uuid,
  kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
  tool_title: z.string().optional(),
  input: z.record(z.unknown()).optional(),
  options: z.array(PermissionOptionShape),
});

export const PermissionResolvedFrame = z.object({
  type: z.literal('permission_resolved'),
  task_id: Uuid,
  session_id: Uuid,
});

const AgentCommandShape = z.object({
  name: z.string(),
  description: z.string().optional(),
});

export const AgentCommandsFrame = z.object({
  type: z.literal('agent_commands'),
  task_id: Uuid,
  session_id: Uuid,
  commands: z.array(AgentCommandShape),
});

// agent-status-normalize (#10): published by BooCoder on the per-session channel
// when an external agent's normalized status changes (turn start/end, permission
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
// pair and resets on chat switch. `reason` is a free-form discriminator
// (turn_start / turn_complete / failed / crashed / permission_request /
// permission_resolved).
export const AgentStatusUpdatedFrame = z.object({
  type: z.literal('agent_status_updated'),
  chat_id: Uuid,
  agent: z.string().min(1),
  status: AgentStatusValue,
  reason: z.string().optional(),
  at: IsoTimestamp,
});

const FlowStepManifestEntry = z.object({
  step_id: z.string().min(1),
  agent: z.string().min(1),
  kind: z.enum(['agent', 'code']),
  chat_id: Uuid,
  label: z.string().min(1),
});

// Published once when a flow run starts. Carries the full step manifest so
// the OrchestratorPane can build its roster immediately.
export const FlowRunStartedFrame = z.object({
  type: z.literal('flow_run_started'),
  run_id: Uuid,
  flow_name: z.string().min(1),
  band: z.enum(['small', 'medium', 'large']),
  steps: z.array(FlowStepManifestEntry),
  stream_seq: z.number().int().min(0).optional(),
});

// Published on every step status change and on run completion. `report` is
// present (and non-null) only when `run_status === 'completed'` — it rides here
// rather than a dedicated frame (D-6). Phase 6: `cancelled` added to both enums
// so the stop route can publish cancel transitions (DB CHECKs already included it).
export const FlowRunStepUpdatedFrame = z.object({
  type: z.literal('flow_run_step_updated'),
  run_id: Uuid,
  step_id: z.string().min(1),
  status: z.enum(['pending', 'running', 'completed', 'failed', 'skipped', 'cancelled', 'timed_out']),
  run_status: z.enum(['running', 'completed', 'failed', 'cancelled']).optional(),
  report: z.string().optional(),
  stream_seq: z.number().int().min(0).optional(),
});

export const AgentMessageFrame = z.object({
  type: z.literal('agent_message'),
  run_id: Uuid,
  sender_step_id: z.string().min(1),
  content: z.string(),
  channel: z.string().optional(),
  stream_seq: z.number().int().min(0).optional(),
});

const ContestantManifestEntry = z.object({
  id: Uuid,
  identity: z.string().min(1),
  model: z.string().min(1),
  lane: z.enum(['local', 'cloud']),
});

// Published once when a battle starts. Carries the contestant roster so the
// ArenaPane can build its grid immediately.
export const BattleStartedFrame = z.object({
  type: z.literal('battle_started'),
  battle_id: Uuid,
  battle_type: z.enum(['coding', 'qa']),
  prompt: z.string(),
  contestants: z.array(ContestantManifestEntry),
  stream_seq: z.number().int().min(0).optional(),
});

// Published on every contestant status change or streaming update.
// `delta` carries the latest chunk of streaming output while status='running'.
// `battle_status` is present only on the final transition that closes the battle.
export const ContestantUpdatedFrame = z.object({
  type: z.literal('contestant_updated'),
  battle_id: Uuid,
  contestant_id: Uuid,
  status: z.enum(['queued', 'running', 'done', 'error']).optional(),
  duration_ms: z.number().int().nonnegative().optional(),
  tokens_per_sec: z.number().nonnegative().optional(),
  battle_status: z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']).optional(),
  delta: z.string().optional(),
  error: z.string().optional(),
  stream_seq: z.number().int().min(0).optional(),
});

// Published when battle-level state changes that don't ride on a contestant
// update: analysis finished, winner set, cross-exam verdict ready. The pane
// uses this to update its analysis panel and winner badge without a refetch.
// Fields are all optional — publishers include only what changed.
export const BattleUpdatedFrame = z.object({
  type: z.literal('battle_updated'),
  battle_id: Uuid,
  status: z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']).optional(),
  winner_contestant_id: Uuid.nullable().optional(),
  analysis_ready: z.boolean().optional(),
  cross_exam_id: Uuid.optional(),
  stream_seq: z.number().int().min(0).optional(),
});

export const AgentSnapshotFrame = z.object({
  type: z.literal('agent_snapshot'),
  chat_id: z.string().uuid(),
  agent: z.string().nullable().optional(),
  model: z.string(),
  mode: z.string().nullable().optional(),
  turn_number: z.number().int().nonnegative(),
});

export const ToolTraceStartFrame = z.object({
  type: z.literal('tool_trace_start'),
  trace_id: z.string().uuid(),
  message_id: z.string().uuid(),
  chat_id: z.string().uuid(),
  tool_name: z.string().min(1),
  tool_input: z.record(z.unknown()),
  started_at: z.string().datetime(),
  stream_seq: z.number().int().min(0).optional(),
});

export const ToolTraceFinishFrame = z.object({
  type: z.literal('tool_trace_finish'),
  trace_id: z.string().uuid(),
  message_id: z.string().uuid(),
  chat_id: z.string().uuid(),
  tool_name: z.string().min(1),
  tool_output: z.union([z.string(), z.null()]).optional(),
  latency_ms: z.number().int().nonnegative().optional(),
  tokens_used: z.number().int().nonnegative().nullable().optional(),
  cache_tokens: z.number().int().nonnegative().nullable().optional(),
  reasoning_tokens: z.number().int().nonnegative().nullable().optional(),
  error: z.string().optional(),
  outcome: z.string().optional(),
  finished_at: z.string().datetime(),
  stream_seq: z.number().int().min(0).optional(),
});

// Host liveness state. Hosts are only ever 'connected' or 'down' — the WS
// connection pill carries a separate 'reconnecting' state (see ControlConnection
// in apps/web), which is unrelated to per-host liveness.
const HostLivenessValue = z.enum(['connected', 'down']);

// Control fleet snapshot/delta: full snapshot on join + seq-stamped state deltas.
export const ControlFleetFrame = z.object({
  type: z.literal('control_fleet'),
  seq: z.number().int().nonnegative(),
  hosts: z.array(z.object({
    providerId: z.string(),
    liveness: HostLivenessValue,
    lastSeenAt: z.string().nullable(),
    seq: z.number().int().nonnegative(),
    models: z.array(z.object({
      model: z.string(),
      state: z.string(),
      ts: z.string(),
      ttlDeadline: z.string().nullable(),
      inflight: z.number().int().nonnegative(),
    })),
  })),
});

// Control activity: new request rows (live feed).
export const ControlActivityFrame = z.object({
  type: z.literal('control_activity'),
  seq: z.number().int().nonnegative(),
  providerId: z.string(),
  entry: z.object({
    id: z.number().int(),
    ts: z.string(),
    model: z.string().nullable(),
    reqPath: z.string().nullable(),
    statusCode: z.number().nullable(),
    durationMs: z.number().nullable(),
  }),
});

// Control perf sample: appended samples per host.
export const ControlPerfFrame = z.object({
  type: z.literal('control_perf'),
  seq: z.number().int().nonnegative(),
  providerId: z.string(),
  ts: z.string(),
  gpu: z.unknown(),
  sys: z.unknown(),
});

// Control log: {provider_id, source: proxy|upstream, line} batches.
export const ControlLogFrame = z.object({
  type: z.literal('control_log'),
  seq: z.number().int().nonnegative(),
  providerId: z.string(),
  source: z.enum(['proxy', 'upstream', 'model']),
  line: z.string(),
  // Server-emit timestamp (ISO). Optional for backward compat; the web stamps
  // ingest time when absent. Added so log rows show event time, not render time.
  ts: z.string().optional(),
});

// Control job: bench/eval run progress events.
export const ControlJobFrame = z.object({
  type: z.literal('control_job'),
  seq: z.number().int().nonnegative(),
  jobType: z.enum(['bench', 'eval', 'action']),
  jobId: z.string(),
  status: z.enum(['queued', 'running', 'completed', 'failed']),
  detail: z.record(z.unknown()).optional(),
});

const ConflictSeverityValue = z.enum(['same_line', 'adjacent_line', 'different_area']);

export const CollisionWarningFrame = z.object({
  type: z.literal('collision_warning'),
  file_path: z.string().min(1),
  worktrees: z.array(z.string().min(1)),
  agents: z.array(z.string().min(1)),
  severity: ConflictSeverityValue,
});

export const PtyExitedFrame = z.object({
  type: z.literal('pty_exited'),
  session_id: z.string().min(1).max(64),
  pane_id: z.string().min(1).max(64),
  exit_code: z.number().int(),
  last_lines: z.array(z.string()),
  session_title: z.string().nullable().optional(),
  session_description: z.string().nullable().optional(),
  parent_agent: z.string().nullable().optional(),
  timed_out: z.boolean(),
});

const TextChannelPayload = z.object({
  message_id: Uuid,
  chat_id: Uuid.optional(),
  content: z.string(),
  compare_group_id: z.string().uuid().optional(),
});

const ToolCallChannelPayload = z.object({
  message_id: Uuid,
  chat_id: Uuid.optional(),
  tool_call: ToolCallShape,
});

const ToolResultChannelPayload = z.object({
  tool_message_id: Uuid,
  chat_id: Uuid.optional(),
  tool_call_id: ToolCallId,
  output: z.unknown(),
  truncated: z.boolean(),
  error: z.string().optional(),
  diff: z.string().optional(),
});

const StatusChannelPayload = z.object({
  message_id: Uuid,
  chat_id: Uuid.optional(),
  status: z.enum(['running', 'complete', 'cancelled', 'failed']).optional(),
  tokens_used: z.number().int().nonnegative().nullable().optional(),
  ctx_used: z.number().int().nonnegative().nullable().optional(),
  ctx_max: z.number().int().positive().nullable().optional(),
  cache_tokens: z.number().int().nonnegative().nullable().optional(),
  reasoning_tokens: z.number().int().nonnegative().nullable().optional(),
  started_at: IsoTimestamp.nullable().optional(),
  finished_at: IsoTimestamp.nullable().optional(),
  model: z.string().nullable().optional(),
  metadata: OpaqueObject.nullable().optional(),
});

const ErrorChannelPayload = z.object({
  message_id: Uuid.optional(),
  chat_id: Uuid.optional(),
  error: z.string(),
  reason: ErrorReasonValue.optional(),
});

const ChannelDeltaPayload = z.discriminatedUnion('channel', [
  z.object({ channel: z.literal('text'), ...TextChannelPayload.shape }),
  z.object({ channel: z.literal('tool_call'), ...ToolCallChannelPayload.shape }),
  z.object({ channel: z.literal('tool_result'), ...ToolResultChannelPayload.shape }),
  z.object({ channel: z.literal('status'), ...StatusChannelPayload.shape }),
  z.object({ channel: z.literal('error'), ...ErrorChannelPayload.shape }),
]);

export const ChannelDeltaFrame = z.object({
  type: z.literal('channel_delta'),
  seq: z.number().int().nonnegative(),
  channel: z.union([
    z.literal('text'), z.literal('tool_call'),
    z.literal('tool_result'), z.literal('status'), z.literal('error'),
  ]),
  message_id: Uuid.optional(),
  chat_id: Uuid.optional(),
  content: z.string().optional(),
  tool_call: ToolCallShape.optional(),
  tool_message_id: Uuid.optional(),
  tool_call_id: ToolCallId.optional(),
  output: z.unknown().optional(),
  truncated: z.boolean().optional(),
  diff: z.string().optional(),
});

export const WsFrameSchema = z.discriminatedUnion('type', [
  // per-session
  SnapshotFrame,
  AgentSnapshotFrame,
  MessageStartedFrame,
  DeltaFrame,
  ReasoningDeltaFrame,
  ToolCallFrame,
  ToolResultFrame,
  MessageCompleteFrame,
  UsageFrame,
  MessagesDeletedFrame,
  ChatRenamedFrame,
  CompactedFrame,
  ErrorFrame,
  PermissionRequestedFrame,
  PermissionResolvedFrame,
  AgentCommandsFrame,
  AgentStatusUpdatedFrame,
  // orchestrator
  FlowRunStartedFrame,
  FlowRunStepUpdatedFrame,
  // arena
  BattleStartedFrame,
  ContestantUpdatedFrame,
  BattleUpdatedFrame,
  // tool trace
  ToolTraceStartFrame,
  ToolTraceFinishFrame,
  // collision warning
  CollisionWarningFrame,
  // pty_exited (booterm)
  PtyExitedFrame,
  // channel-delta (streaming v2)
  ChannelDeltaFrame,
  // inter-agent message
  AgentMessageFrame,
  // BooControl fleet frames
  ControlFleetFrame,
  ControlActivityFrame,
  ControlPerfFrame,
  ControlLogFrame,
  ControlJobFrame,
  // per-user
  ChatStatusFrame,
  SessionUpdatedFrame,
  SessionRenamedFrame,
  SessionCreatedFrame,
  SessionArchivedFrame,
  SessionDeletedFrame,
  SessionWorkspaceUpdatedFrame,
  ChatCreatedFrame,
  ChatUpdatedFrame,
  ChatArchivedFrame,
  ChatUnarchivedFrame,
  ChatDeletedFrame,
  ProjectCreatedFrame,
  ProjectArchivedFrame,
  ProjectUnarchivedFrame,
  ProjectUpdatedFrame,
  ProjectDeletedFrame,
]);

export type WsFrame = z.infer<typeof WsFrameSchema>;

// Convenience: the set of known frame types. Useful for the publishFrame
// helper to log the offending type name when validation fails. Kept in sync
// by the drift test in src/__tests__/ws-frames.test.ts.
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
  'snapshot',
  'agent_snapshot',
  'message_started',
  'delta',
  'reasoning_delta',
  'tool_call',
  'tool_result',
  'message_complete',
  'usage',
  'messages_deleted',
  'chat_renamed',
  'compacted',
  'error',
  'permission_requested',
  'permission_resolved',
  'agent_commands',
  'agent_status_updated',
  'flow_run_started',
  'flow_run_step_updated',
  'battle_started',
  'contestant_updated',
  'battle_updated',
  'tool_trace_start',
  'tool_trace_finish',
  'collision_warning',
  'pty_exited',
  'channel_delta',
  'agent_message',
  'chat_status',
  'session_updated',
  'session_renamed',
  'session_created',
  'session_archived',
  'session_deleted',
  'session_workspace_updated',
  'chat_created',
  'chat_updated',
  'chat_archived',
  'chat_unarchived',
  'chat_deleted',
  'project_created',
  'project_archived',
  'project_unarchived',
  'project_updated',
  'project_deleted',
  // BooControl fleet frames
  'control_fleet',
  'control_activity',
  'control_perf',
  'control_log',
  'control_job',
] as const;

// Named type aliases for BooControl frame types, derived from the Zod schema.
// Consumers import these instead of hand-writing the shapes locally.
export type ControlFleetFrameType = Extract<WsFrame, { type: 'control_fleet' }>;
export type ControlActivityFrameType = Extract<WsFrame, { type: 'control_activity' }>;
export type ControlPerfFrameType = Extract<WsFrame, { type: 'control_perf' }>;
export type ControlLogFrameType = Extract<WsFrame, { type: 'control_log' }>;
export type ControlJobFrameType = Extract<WsFrame, { type: 'control_job' }>;
