import { escapeHtml } from "./html.js"; // Mirror of `maestro-protocol` JSON shapes. Keep in sync with // crates/maestro-protocol/src/lib.rs. export type Uuid = string; export type IsoTimestamp = string; export type SessionStatus = "Running" | "Dead"; /** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340). * Absent on payloads from daemons that predate the field. `Error` is only set * when an agent reports failure (#105); the daemon never infers it. */ export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error"; export interface Session { id: Uuid; cwd: string; command: string; env: Record; metadata: Record; status: SessionStatus; created_at: IsoTimestamp; /** Current foreground command in the pane (e.g. `claude`, `bash`), as the * daemon's activity watcher observed it. Absent until observed (NAP-340). */ foreground?: string | null; /** Inferred activity. Defaults to `Idle` on the wire when omitted. */ activity?: ActivityState; /** Short human string an agent reported alongside its state (#105) — e.g. * why it is in `Error`. Shown on a sidebar card's meta line while the * report is in force; cleared when the report lapses or is replaced. */ activity_detail?: string | null; /** Wall-clock of the most recent observed PTY output (NAP-340). */ last_activity_at?: IsoTimestamp; /** Wall-clock of when the session entered its current `activity` state * (#102) — distinct from `last_activity_at`, which only tracks PTY output. * Defaults to the Unix epoch on payloads from daemons that predate the * field. */ activity_since?: IsoTimestamp; /** The pane's *current* working directory, as the daemon's activity watcher * observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir). * Drives the directory-based session tree. Absent until first observed. */ current_cwd?: string | null; /** What currently blocks an unforced kill of this session (#83), as the * daemon's watcher last probed it. Absent until probed and for sessions * without a worktree; all counts default to 0 on the wire for older * daemons. */ blocked_work?: InTheWay | null; /** The worktree the daemon created for this session, absent when it did not * create one. `base_repo` is the repository's main checkout, which is what * the session tree groups by (#99). */ worktree?: SessionWorktree | null; /** Wall-clock of when this session last became the pane a client was showing * (#145) — stamped by both `POST /api/sessions/:id/focus` and opening * `/api/attach/:id`. Absent until the session has been focused once, which is * how the command bar's empty query sorts a never-focused session after every * session that has one (`docs/COMMAND-BAR.md`). */ last_focused_at?: IsoTimestamp | null; /** The ACP session id the `claude-code-acp` adapter returned from * `session/new` (#134). Only a `Structured` session whose handshake * succeeded has one; a respawn passes it to `session/load` (#135). */ acp_session_id?: string | null; /** Terminal (PTY bytes) or structured (ACP rows) (#133). Fixed at spawn and * immutable for the session's life. Defaults to `"Terminal"` on the wire, so * absent means terminal. */ view?: SessionView; } /** A session's process shape (#133): mirrors `maestro_protocol::SessionView`. */ export type SessionView = "Terminal" | "Structured"; /** A session's daemon-created worktree (`SessionWorktree` on the wire). */ export interface SessionWorktree { path: string; base_repo: string; } /** One directory's immediate subdirectories (NAP-329). */ export interface DirListing { /** Resolved absolute directory that was listed (after ~/relative expansion). */ path: string; /** Immediate subdirectory names, sorted case-insensitively. */ entries: string[]; /** True when the listing was capped server-side. */ truncated?: boolean; } /** A spawn-form command preset advertised by the daemon (#22/#26). The `custom` * preset has an empty `command` and reveals the free-text command input. */ export interface SpawnPreset { id: string; label: string; command: string; } /** `GET /api/info` — the daemon's identity plus its spawn presets (#26). * Mirrors the daemon-side `DaemonInfo`. */ export interface DaemonInfo { hostname: string; version: string; /** Home dir of the user the daemon runs as; used to prefill the spawn form's * working directory. Empty/absent for older daemons. */ home_dir?: string; /** Command presets (claude/opencode/zsh + custom), config-resolved. */ presets: SpawnPreset[]; /** Which commit the daemon was built from. Absent on older daemons. */ build?: BuildInfo; /** Identity of the daemon *process*, regenerated on every start — a changed * value across a reconnect means the daemon restarted. Absent on older * daemons, which the restart flow treats as "cannot tell". */ instance?: string; /** Canonical leader key the operator configured (`[tui].leader`, default * `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or * empty on an older daemon; the client then keeps its own default. */ leader?: string; } /** Build provenance of the running daemon: what the status indicator names in * its tooltip, and what tells an upgrade apart from a plain restart. Every * field is empty when the binary was built outside a git checkout. */ export interface BuildInfo { /** Abbreviated commit hash. */ commit: string; /** Commit date, RFC 3339. */ commit_date: string; /** Whether the tree had uncommitted changes at build time. */ dirty: boolean; } /** One deduplicated recent working dir for the spawn form's recents picker * (#24/#26). */ export interface RecentDir { cwd: string; last_command: string; last_used: IsoTimestamp; } /** `GET /api/recent-dirs` — the daemon's recent working dirs (#26). */ export interface RecentDirs { dirs: RecentDir[]; truncated?: boolean; } /** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or absent * when the path isn't inside a repo (#26). */ export interface GitRoot { root?: string; } /** `GET /api/names?count=N` — a batch of fresh session handles (#53). Mirrors * `maestro_protocol::NameBatch`. */ export interface NameBatch { names: string[]; } /** One discovered git repository (or linked worktree) for `GET /api/projects` * (#54). Mirrors `maestro_protocol::Project`. */ export interface Project { /** Absolute path of the repository/worktree toplevel. */ path: string; /** Basename of `path`. */ name: string; is_worktree?: boolean; /** The main checkout this worktree belongs to; absent on plain repos. */ base_repo?: string; /** Later of the repo's git HEAD mtime and the newest spawn-history use of * any directory inside it; absent when neither is known. */ last_activity?: IsoTimestamp; /** Command last spawned in this project, from spawn history. */ last_command?: string; } /** `GET /api/projects` — every discovered project, sorted by the daemon * newest-activity-first (#54). */ export type Projects = Project[]; export interface WorktreeSpec { base_repo: string; branch: string; /** When false, `branch` already exists and is checked out as-is (#61). * Defaults to true (create a new branch) on older daemons. */ create_branch?: boolean; } /** One local branch of the enclosing repository, from `GET /api/branches` * (#55). Mirrors `maestro_protocol::BranchInfo`. */ export interface BranchInfo { /** Short branch name (`main`, `feature/foo`). */ name: string; /** Whether this branch is the main checkout's current HEAD. */ is_head: boolean; /** Toplevel path of the linked worktree holding this branch, if any. */ worktree?: string | null; } /** `GET /api/branches?path=` — the local branches of one repository (#55). */ export type Branches = BranchInfo[]; /** What currently blocks an unforced kill of a worktree's session (#80), * recomputed by the daemon on every read. All counts default to 0 on the * wire for older daemons. */ export interface InTheWay { uncommitted_files?: number; untracked_files?: number; unpushed_commits?: number; /** Lines added across the tracked changes, staged and unstaged together. */ insertions?: number; /** Lines removed across the same tracked changes. */ deletions?: number; } /** One fragment of the sidebar's compact blocked-work stat (docs/SIDEBAR.md). * `kind` picks the colour; `text` is what the client prints. */ export interface BlockerStat { kind: "add" | "del" | "files" | "untracked" | "unpushed"; text: string; } /** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. * * The line counts cover tracked changes only, so a tree whose only tracked * change carries no line delta — a mode change, a rename — falls back to the * file count (`~2`) rather than claiming `+0 -0`. Both clients render exactly * these fragments; the spelled-out {@link formatInTheWay} survives in the * tooltip and in the kill refusal. */ export function blockerStats(w: InTheWay): BlockerStat[] { const stats: BlockerStat[] = []; const added = w.insertions ?? 0; const removed = w.deletions ?? 0; const changed = w.uncommitted_files ?? 0; if (added > 0 || removed > 0) { if (added > 0) stats.push({ kind: "add", text: `+${added}` }); if (removed > 0) stats.push({ kind: "del", text: `-${removed}` }); } else if (changed > 0) { stats.push({ kind: "files", text: `~${changed}` }); } if ((w.untracked_files ?? 0) > 0) stats.push({ kind: "untracked", text: `?${w.untracked_files}` }); if ((w.unpushed_commits ?? 0) > 0) stats.push({ kind: "unpushed", text: `\u2191${w.unpushed_commits}` }); return stats; } /** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by * the kill refusal, the TUI and this client, so no two clients can describe * the same tree differently. Zero parts are omitted; all-zero renders * "no unsaved work". */ export function formatInTheWay(w: InTheWay): string { const parts: string[] = []; const push = (n: number | undefined, unit: string): void => { if (!n || n <= 0) return; parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`); }; push(w.uncommitted_files, "uncommitted file"); push(w.untracked_files, "untracked file"); push(w.unpushed_commits, "unpushed commit"); return parts.length > 0 ? parts.join(", ") : "no unsaved work"; } /** #83: this session's blocked-work breakdown when something actually blocks * an unforced kill; `null` while clear or not yet probed. Render it with * {@link formatInTheWay} so every client phrases it identically. */ export function sessionBlockers(s: Session): InTheWay | null { const w = s.blocked_work; if (!w) return null; const blocked = (w.uncommitted_files ?? 0) > 0 || (w.untracked_files ?? 0) > 0 || (w.unpushed_commits ?? 0) > 0; return blocked ? w : null; } export interface CreateSessionRequest { cwd: string; command: string; env?: Record; metadata?: Record; worktree?: WorktreeSpec; view?: SessionView; } export type CatalogEvent = | { type: "Snapshot"; sessions: Session[] } | { type: "SessionAdded"; session: Session } | { type: "SessionRemoved"; id: Uuid } | { type: "SessionUpdated"; session: Session } | { type: "Lagged"; skipped: number }; export type ClientControl = | { type: "Resize"; cols: number; rows: number } | { type: "Ping" } | { type: "Pong" } | { type: "Error"; code: string; message: string }; export interface ApiError { error: { code: string; message: string; /** Set only by the kill guard (#79): what blocked the unforced kill. */ unsaved_work?: InTheWay; }; } export function sessionName(s: Session): string { return customSessionName(s) ?? s.command; } /** The session's own name, without the command fallback (#125): `undefined` * for a session that was never given one. */ export function customSessionName(s: Session): string | undefined { const meta = s.metadata as Record; const name = meta?.name; if (typeof name === "string" && name.length > 0) return name; return undefined; } /** Where a session's name came from (#131). Mirrors * `maestro_protocol::NameSource`. */ export type NameSource = "User" | "Generated" | "Haiku"; /** Read `metadata.name_source`, leniently: anything unrecognised — including * a session named before the field existed — is a settled `User` name, so an * existing name is never dimmed or overwritten. */ export function nameSource(s: Session): NameSource { const meta = s.metadata as Record; const source = meta?.name_source; if (source === "Generated" || source === "Haiku") return source; return "User"; } /** Whether line 1 renders the name as provisional (#131): a handle the daemon * generated at spawn and may still replace with one derived from the first * prompt. See docs/SIDEBAR.md. */ export function isProvisionalName(s: Session): boolean { return customSessionName(s) !== undefined && nameSource(s) === "Generated"; } /** Longest session name the daemon stores (#125), in characters. Mirrors * `maestro_protocol::MAX_SESSION_NAME_LEN`. */ export const MAX_SESSION_NAME_LEN = 64; /** Trim a requested session name and cap it at {@link MAX_SESSION_NAME_LEN} * characters. Empty means "clear the name", so the card falls back to the * command. Mirrors `maestro_protocol::normalize_session_name` — the daemon * applies the same rule, this only keeps the UI honest about what it will * get back. Uses the spread so the cap counts code points, not UTF-16 units. */ export function normalizeSessionName(name: string): string { return [...name.trim()].slice(0, MAX_SESSION_NAME_LEN).join(""); } /** The directory a session lives in: its live `current_cwd` if the daemon has * observed one, else the spawn `cwd`. */ export function sessionCwd(s: Session): string | undefined { if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd; return s.cwd || undefined; } /** Where a session sits in the directory tree. A worktree session belongs to * its repository, not to the worktree (#99): otherwise `~/tea/agent-skills` * grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one * session of the same name, and the repository stops being where you look for * the work. `base_repo` is the main checkout — the daemon resolves it. */ export function sessionTreePath(s: Session): string | undefined { const repo = s.worktree?.base_repo; if (typeof repo === "string" && repo) return repo; return sessionCwd(s); } /** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`; * absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */ export function abbreviateCwd(cwd: string, home?: string): string { if (home && (cwd === home || cwd.startsWith(`${home}/`))) { return `~${cwd.slice(home.length)}`; } return cwd; } // --------------------------------------------------------------------------- // Structured-session projections (#133/#140). Mirror of // `maestro_protocol`'s "Structured-session projections" section. `data`, // `ReducedState.plan/usage/context/pending_approval` and every `Acp*` type // below are the daemon's opaque ACP payloads (`serde_json::Value` on the // wire) — shaped to `claude-code-acp`'s ACP v1 schema, not covered by // maestro-protocol's own compatibility guarantee. Treat every field as // optional and degrade gracefully rather than trust this shape exactly. // --------------------------------------------------------------------------- /** `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */ export type TranscriptRowKind = | "user" | "agent_text" | "agent_thought" | "tool_call" | "tool_diff" | "approval" | "plan" | "notice"; /** One row of a structured session's transcript. */ export interface TranscriptRow { id: string; kind: TranscriptRowKind; data: unknown; } /** The complete transcript at a durable event-log position (`WS * /api/structured/:id`'s first message, and every replay page). */ export interface TranscriptSnapshot { cursor: number; rows: TranscriptRow[]; } /** A change to one transcript row, addressed by `id`. */ export type TranscriptDelta = | { type: "append"; row: TranscriptRow } | { type: "patch"; row: TranscriptRow } | { type: "remove"; id: string }; /** Per-turn indicators derived from the ACP stream — read verbatim, never * re-derived client-side (docs/STRUCTURED-VIEW.md). */ export interface TurnState { responding: boolean; thinking: boolean; active_tool_calls: string[]; } /** A whole-session view derived from ACP notifications. `plan`/`usage`/ * `context`/`pending_approval` are opaque ACP JSON — see the module note * above. `pending_approval` is `{nonce, request: AcpRequestPermissionRequest}` * while a permission request is live, absent otherwise. */ export interface ReducedState { turn: TurnState; pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null; plan?: AcpPlan | null; usage?: AcpUsage | null; context?: AcpUsage | null; model?: string | null; mode?: string | null; } /** Server messages on `WS /api/structured/:id`. */ export type StructuredEvent = | { type: "transcript_snapshot"; snapshot: TranscriptSnapshot } | { type: "reduced_state"; state: ReducedState } | { type: "projection_update"; transcript?: TranscriptDelta | null; state: ReducedState }; /** `POST /api/sessions/:id/structured/prompt` body. */ export interface StructuredPromptRequest { prompt: string; } /** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON * string (a bracketless Rust enum), not `{decision: ...}`. */ export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny"; // ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ---- /** `ContentBlock`. Only `text` is common in practice; the rest degrade to a * generic placeholder rather than being dropped silently. */ export type AcpContentBlock = | { type: "text"; text: string } | { type: "image"; mimeType?: string } | { type: "audio"; mimeType?: string } | { type: "resource_link"; uri?: string; name?: string } | { type: "resource"; resource?: unknown }; /** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */ export interface AcpContentChunk { content: AcpContentBlock; messageId?: string | null; } /** `ToolKind`, snake_case on the wire. */ export type AcpToolKind = | "read" | "edit" | "delete" | "move" | "search" | "execute" | "think" | "fetch" | "switch_mode" | "other"; /** `ToolCallStatus`, snake_case on the wire. */ export type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; /** `Diff` — a file modification. `oldText` absent means a new file. */ export interface AcpDiff { path: string; oldText?: string | null; newText: string; } /** `ToolCallContent`. */ export type AcpToolCallContent = | { type: "content"; content: AcpContentBlock } | ({ type: "diff" } & AcpDiff) | { type: "terminal"; terminalId?: string }; export interface AcpToolCallLocation { path: string; line?: number | null; } /** `ToolCall` (a `ToolCall`/`ToolDiff` row's `data`) and, loosely, the * flattened `ToolCallUpdate` an approval's `toolCall` field carries — the * two share every field below, just with different subsets required. */ export interface AcpToolCallLike { toolCallId?: string; title?: string; /** Unstable ACP capability; absent when the adapter doesn't send one. */ name?: string | null; kind?: AcpToolKind; status?: AcpToolCallStatus; content?: AcpToolCallContent[]; locations?: AcpToolCallLocation[]; rawInput?: unknown; rawOutput?: unknown; } /** `PlanEntryPriority` / `PlanEntryStatus`, snake_case on the wire. */ export type AcpPlanPriority = "high" | "medium" | "low"; export type AcpPlanEntryStatus = "pending" | "in_progress" | "completed"; export interface AcpPlanEntry { content: string; priority: AcpPlanPriority; status: AcpPlanEntryStatus; } /** `Plan` — `ReducedState.plan`. */ export interface AcpPlan { entries: AcpPlanEntry[]; } /** `UsageUpdate` — `ReducedState.usage`/`.context` (the daemon folds one * update into both fields). */ export interface AcpUsage { used: number; size: number; cost?: { amount: number; currency: string } | null; } export interface AcpPermissionOption { optionId: string; name: string; kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; } /** `RequestPermissionRequest`, serialized without its nonce (the nonce lives * only in `ReducedState.pending_approval.nonce`) — an `Approval` row's `data`, * and `ReducedState.pending_approval.request`. */ export interface AcpRequestPermissionRequest { sessionId: string; toolCall: AcpToolCallLike; options: AcpPermissionOption[]; } /** `~/git.naps.pt/naps62/maestro` → `~/g/n/maestro`: keep the last segment * verbatim, collapse every earlier one to its first character. Two-or-fewer * segments are left alone (nothing to gain). Returns escaped HTML with the * collapsed stem wrapped in `.folder-stem` so callers' CSS can dim it. Shared * by the sidebar tree and the spawn dialog's recent-directory picker so both * abbreviate paths identically. */ export function abbreviateFolderName(name: string): string { const parts = name.split("/"); if (parts.length <= 2) return escapeHtml(name); const last = parts[parts.length - 1]; const stem = parts .slice(0, -1) .map((p) => (p === "~" ? "~" : Array.from(p)[0] ?? "")) .join("/"); return `${escapeHtml(stem)}/${escapeHtml(last)}`; }