feat(run): replace RunHandle/RunStartArgs types and api.run for the tmux backend

This commit is contained in:
2026-08-12 10:24:45 +07:00
parent 9b8d9bbe39
commit 24f13911fe
2 changed files with 42 additions and 188 deletions
+32 -145
View File
@@ -1474,110 +1474,32 @@ export const api = {
/** Spawn/manage headless or conversational `claude` CLI child processes /** Spawn/manage headless or conversational `claude` CLI child processes
* launched from the dashboard's Run page, and stream their output. */ * launched from the dashboard's Run page, and stream their output. */
run: { run: {
/** /** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
* GET /api/run - currently tracked runs (in-memory handles) plus
* concurrency limits.
* @returns {@link RunListResponse} — live handles + `maxConcurrent`/`activeCount`.
*/
list: () => request<RunListResponse>("/run"), list: () => request<RunListResponse>("/run"),
/** /** GET /api/run/history - persisted run history from `dashboard_runs`. */
* GET /api/run/history - persisted run history from the `dashboard_runs`
* table, including runs whose in-memory handle has since been reaped.
* Optionally filter by lane.
*
* `limit` defaults to 50 when the caller omits it and is always sent as a
* query param.
*
* @param limit Max history rows to return (default 50).
* @param options Optional filters like laneId.
* @returns `{ items }` — {@link DashboardRunHistoryItem} rows, newest-first.
*/
history: (limit = 50, options?: { laneId?: number }) => { history: (limit = 50, options?: { laneId?: number }) => {
const qs = new URLSearchParams({ limit: String(limit) }); const qs = new URLSearchParams({ limit: String(limit) });
if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId)); if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId));
return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`); return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`);
}, },
/** /** GET /api/run/binary - whether `claude` was found on PATH. */
* GET /api/run/binary - whether a `claude` executable was found on PATH.
*
* Lets the Run page disable/enable the "start" affordance and show where the
* CLI resolved from (or that it's missing).
*
* @returns `{ found, path }` — whether a binary was located and its path.
*/
binary: () => request<{ found: boolean; path: string | null }>("/run/binary"), binary: () => request<{ found: boolean; path: string | null }>("/run/binary"),
/** /** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
* GET /api/run/cwds - suggested working directories for the cwd picker. tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
* @returns `{ items }` — {@link CwdSuggestion} entries (dashboard/home/recent). /** GET /api/run/cwds - suggested working directories for the cwd picker. */
*/
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"), cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** /** GET /api/run/files - path-completion suggestions under `cwd`. */
* GET /api/run/files - path-completion suggestions under `cwd`, filtered
* by an optional query fragment `q`.
*
* Backs the file/@-mention autocomplete when composing a run prompt: `cwd`
* is always sent; `q` is appended only when non-empty to narrow matches.
*
* @param cwd The directory to complete paths within.
* @param q Optional partial fragment to filter suggestions by.
* @returns `{ items }` — matching path strings under `cwd`.
*/
files: (cwd: string, q?: string) => { files: (cwd: string, q?: string) => {
const qs = new URLSearchParams({ cwd }); const qs = new URLSearchParams({ cwd });
if (q) qs.set("q", q); if (q) qs.set("q", q);
return request<{ items: string[] }>(`/run/files?${qs.toString()}`); return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
}, },
/** /** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
* POST /api/run - spawn a new `claude` child process.
*
* Sends {@link RunStartArgs} (prompt, mode, and optional cwd/model/
* permission-mode/resume/effort). The server spawns the CLI and returns the
* initial {@link RunHandle}; subsequent output is streamed over the
* `run_stream` WebSocket message rather than this response.
*
* @param args The spawn parameters.
* @returns {@link RunHandle} — the freshly created run's handle.
*/
start: (args: RunStartArgs) => start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }), request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
/** /** GET /api/run/:id - one run's current handle. */
* GET /api/run/:id - one run's current handle; pass `envelopes: true` to get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
* also include its buffered stream-json envelopes (for a page refresh /** DELETE /api/run/:id - kill the tmux session. */
* mid-run, since the WS `run_stream` history isn't otherwise replayed).
*
* The `envelopes` flag is translated to `?envelopes=1`. Use it when
* re-hydrating the Run page after a reload: the WebSocket only pushes *new*
* envelopes, so the buffered ones must be pulled once to backfill the view.
*
* @param id The run id.
* @param opts Optional `{ envelopes }` — include buffered stream-json envelopes.
* @returns {@link RunHandle} — the run's handle (with `envelopes` when requested).
*/
get: (id: string, opts?: { envelopes?: boolean }) =>
request<RunHandle>(`/run/${encodeURIComponent(id)}${opts?.envelopes ? "?envelopes=1" : ""}`),
/**
* POST /api/run/:id/message - write `text` to the run's stdin (conversation
* mode only); acked via the `run_input_ack` WS message.
*
* Only meaningful for a run started in "conversation" mode (stdin left
* open). The HTTP response returns just the `messageId`; the actual
* delivery/echo is confirmed asynchronously over the WebSocket.
*
* @param id The run id to send input to.
* @param text The user's follow-up message written to the CLI's stdin.
* @returns `{ messageId }` — id correlating this input with its `run_input_ack`.
*/
send: (id: string, text: string) =>
request<{ messageId: string }>(`/run/${encodeURIComponent(id)}/message`, {
method: "POST",
body: JSON.stringify({ text }),
}),
/**
* DELETE /api/run/:id - forcibly terminate a running process.
*
* @param id The run id to kill.
* @returns `{ ok: true }` — acknowledgement that termination was requested.
*/
kill: (id: string) => kill: (id: string) =>
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }), request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
}, },
@@ -2506,78 +2428,49 @@ export interface CcHookScripts {
// mirror the CLI's own vocabulary so the dashboard can drive the CLI faithfully. // mirror the CLI's own vocabulary so the dashboard can drive the CLI faithfully.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** "headless" runs to completion unattended and streams only output;
* "conversation" keeps stdin open so the user can send follow-up messages. */
export type RunMode = "headless" | "conversation";
/** Lifecycle of a spawned `claude` process, mirrored in `RunHandle.status`
* and `RunStatusPayload.status`. "abandoned" is applied by server cleanup
* when a handle is reaped without a clean exit ever being observed. */
export type RunStatus = "spawning" | "running" | "completed" | "error" | "killed" | "abandoned";
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */ /** Maps 1:1 to the `claude --permission-mode` CLI flag. */
export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions"; export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions";
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */ /** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max"; export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
* crashed, claude exited and closed the pane). Computed fresh from tmux
* state on every read, never cached. */
export type RunStatus = "running" | "gone";
/** Body for POST /api/run - parameters for spawning a new `claude` process. */ /** Body for POST /api/run - parameters for starting a lane's terminal run. */
export interface RunStartArgs { export interface RunStartArgs {
/** Initial prompt/task text passed to the CLI. */ laneId: number;
prompt: string;
mode: RunMode;
/** Working directory to launch in; server default applies if omitted. */
cwd?: string; cwd?: string;
/** `--model` value; omitted inherits the CLI's own default (settings.json). */
model?: string; model?: string;
permissionMode?: PermissionMode; permissionMode?: PermissionMode;
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */ /** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
resumeSessionId?: string; resumeSessionId?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** Sent as `claude`'s first positional message once the pane boots; omit
* to just open the pane and let the user type. */
initialPrompt?: string;
} }
/** In-memory (or freshly-fetched) handle for one spawned `claude` process, /** A lane's tmux-backed terminal run — one per lane, id is the tmux session
* from POST/GET /api/run - the live counterpart to {@link DashboardRunHistoryItem}. * name (`ccam-lane-<laneId>`). */
* Where {@link DashboardRunHistoryItem} is the persisted DB row (snake_case,
* survives handle reaping), this is the richer live handle (camelCase, carries
* argv/tails/envelope counters) that only exists while the server tracks it. */
export interface RunHandle { export interface RunHandle {
id: string; id: string;
/** OS process id; null before the process has actually spawned. */ laneId: number | null;
pid: number | null;
mode: RunMode;
cwd: string;
model: string | null;
permissionMode: PermissionMode;
effort: EffortLevel | null;
prompt: string;
/** Full argv the server invoked the CLI with, for debugging. */
argv: string[];
resumeSessionId: string | null;
status: RunStatus; status: RunStatus;
/** Epoch-ms timestamp the process was spawned. */ cwd: string | null;
startedAt: number; model: string | null;
/** Epoch-ms timestamp the process exited; null while still running. */ permissionMode: PermissionMode | null;
endedAt: number | null; effort: EffortLevel | null;
exitCode: number | null; resumeSessionId: string | null;
/** POSIX signal that killed the process (e.g. "SIGTERM"); null otherwise. */ /** Claude Code session id this run created/resumed, once known. */
signal: string | null;
error: string | null;
/** Claude Code session id the run created/resumed, once known. */
sessionId: string | null; sessionId: string | null;
/** Count of stream-json envelopes emitted so far. */ /** ISO timestamp the tmux session was created. */
envelopeCount: number; startedAt: string | null;
/** Last chunk of captured stdout, for a quick inline preview. */
stdoutTail: string;
/** Last chunk of captured stderr, for a quick inline preview. */
stderrTail: string;
envelopes?: unknown[]; // present when fetched with ?envelopes=1
} }
/** Response shape of GET /api/run. */ /** Response shape of GET /api/run. */
export interface RunListResponse { export interface RunListResponse {
items: RunHandle[]; items: RunHandle[];
/** Server-configured cap on simultaneously running processes. */
maxConcurrent: number;
/** Count of runs currently in "spawning"/"running" state. */
activeCount: number;
} }
/** /**
@@ -2591,23 +2484,17 @@ export interface RunListResponse {
*/ */
export interface DashboardRunHistoryItem { export interface DashboardRunHistoryItem {
id: string; id: string;
/** Claude Code session id the run created/resumed; null if never captured. */
session_id: string | null; session_id: string | null;
mode: RunMode;
cwd: string; cwd: string;
model: string | null; model: string | null;
permission_mode: PermissionMode | null; permission_mode: PermissionMode | null;
effort: EffortLevel | null; effort: EffortLevel | null;
resume_session_id: string | null; resume_session_id: string | null;
/** Truncated leading excerpt of the original prompt, for the history list. */
prompt_preview: string | null; prompt_preview: string | null;
status: RunStatus; status: "running" | "killed" | "abandoned";
exit_code: number | null; exit_code: number | null;
started_at: string; started_at: string;
ended_at: string | null; ended_at: string | null;
/** True when an in-memory {@link RunHandle} for this row still exists (so
* the UI can offer live actions like "send message"/"kill"); false once
* the handle has been reaped and only the DB row remains. */
isLive: boolean; isLive: boolean;
} }
+10 -43
View File
@@ -1277,52 +1277,19 @@ export interface UpdateStatusPayload {
fetch_error?: string; fetch_error?: string;
} }
// ───── Interactive run streaming ───── // ───── Terminal run status ─────
// Payloads for the "run a `claude` process from the dashboard" feature. A run is // A lane's terminal run is a tmux session; its own live/dead state is polled
// started via POST /api/run and identified by a `RunHandle` id; the server then // via GET /api/run, not pushed over the WS envelope path. This payload only
// streams stdout envelopes, status transitions, and stdin acks back over the WS. // covers the one thing worth pushing live: a run ending (killed or the pane
// process exiting) so an open Workspace tab can update its badge/switcher
// without polling.
/** Payload for the `run_stream` WebSocket message: one streamed JSON envelope /** Payload for the `run_status` WebSocket message. */
* from a headless/conversation `claude` process started via POST /api/run. */
export interface RunStreamPayload {
/** Id of the `RunHandle` this envelope belongs to. Lets the UI route the chunk
* to the right run panel when several runs stream at once. */
id: string;
/** Raw stream-json envelope emitted by the Claude Code CLI (assistant text
* deltas, tool_use/tool_result blocks, etc.) - shape varies by event type.
* Typed as `unknown` because it's forwarded verbatim and narrowed at render. */
envelope: unknown;
}
/** Payload for the `run_status` WebSocket message: a lifecycle transition for
* a run started via POST /api/run (mirrors `RunHandle.status`). */
export interface RunStatusPayload { export interface RunStatusPayload {
/** Id of the `RunHandle` whose status changed. */ /** The run id (tmux session name, `ccam-lane-<laneId>`). */
id: string; id: string;
/** New run lifecycle state; terminal states are "completed"/"error"/"killed". status: "running" | "gone";
* "spawning" → the child is being started; "running" → streaming output; /** Epoch-ms timestamp of this transition. */
* "killed" → the run was cancelled by the user. */
status: "spawning" | "running" | "completed" | "error" | "killed";
/** Epoch-ms timestamp of this status transition (NOT an ISO string, unlike
* most timestamps in this file). */
at: number;
/** Process exit code; present once status reaches "completed"/"error". 0 means
* a clean exit. */
exitCode?: number;
/** Claude Code session id resumed/created by this run, once known. Lets the UI
* link a run to the {@link Session} it produced. */
sessionId?: string | null;
/** Failure message; present when status is "error". Surfaced in the run panel. */
error?: string;
}
/** Payload for the `run_input_ack` WebSocket message: confirms a message sent
* via POST /api/run/:id/message was written to the child process's stdin. */
export interface RunInputAckPayload {
/** Id of the `RunHandle` the input was delivered to. */
id: string;
/** Echoes the id returned by the `send` call this acks, so the UI can clear
* the matching "sending…" pending state. */
messageId: string;
/** Epoch-ms timestamp the input was delivered (not an ISO string). */
at: number; at: number;
} }