/** * @file startSession.ts — starting a session in a workspace's first free tab * (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot * helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's session * list can use them without pulling the whole home (and react-markdown) into * the main bundle. * @author Nguyễn Ngọc Trí Vĩ */ import i18n from "../../i18n"; import { api, type RunHandle } from "../../lib/api"; import { MAX_SLOTS, slotOfKey } from "../../lib/paneLayout"; /** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */ export function paneRunId(laneId: number, slot: number): string { return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`; } /** * Start a session in the workspace's first free tab. The SERVER picks the slot * (`slot: "auto"`, under its lane lock), because any list the client holds may * already be stale. Resolves to the run id it opened — or, when the session to * resume is already open in a tab (`ESESSIONLIVE`), to that tab, so a second * `--resume` of one session never starts. */ export async function startInFreeTab( laneId: number, body: Record ): Promise { return (await startInFreeTabDetailed(laneId, body)).runId; } /** The workspace a pane belongs to, from its tmux name `agenthub-lane-[-slot]`. */ export function laneOfRun(runId: string): number | null { const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId); return m ? Number(m[1]) : null; } /** `startInFreeTab`, saying also whether the run was STARTED or an existing * pane already held the session (`held` — nothing was sent to it), and the * workspace that pane belongs to (a nested folder's workspace may hold it). */ export async function startInFreeTabDetailed( laneId: number, body: Record ): Promise<{ runId: string; laneId: number; held: boolean }> { try { const r = await api.lanes.action(laneId, "start", { ...body, slot: "auto" }); return { runId: r.run_id ?? paneRunId(laneId, 0), laneId, held: false }; } catch (err) { const e = err as { code?: string; runId?: string }; if (e.code === "ESESSIONLIVE" && e.runId) return { runId: e.runId, laneId: laneOfRun(e.runId) ?? laneId, held: true }; if (e.code === "ERUNLIVE") throw new Error(i18n.t("lanes:home.allBusy")); throw err; } } /** The first pane slot with no live run, or null when all ten are busy. */ export function freeSlot(laneId: number, runs: RunHandle[]): number | null { const busy = new Set( runs .filter((r) => r.laneId === laneId && r.status === "running") .map((r) => slotOfKey(r.slot ?? 0)) ); for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s; return null; }