/** * desktop/src/live-poll.ts — B2: validate + map GET /live-sessions. * * The response body is untrusted external JSON (`unknown`), so every field is * hand-checked before it becomes a SessionStatusSnapshot — matching the repo's * no-validation-library convention (src/config.ts hand-parses everything). * Boundary contract: never throw. Garbage in → [] or filtered-out elements out. * * The /live-sessions endpoint carries id/status/cwd but not approval state, so * pendingApproval is fixed false and gate null here; approval snapshots come * from the hook status bus, not this poll. */ import type { DesktopClaudeStatus, SessionStatusSnapshot } from './types.js' /** Valid ClaudeStatus literals (mirrors backend ClaudeStatus, src/types.ts). */ const VALID_STATUSES: ReadonlySet = new Set([ 'working', 'waiting', 'idle', 'unknown', 'stuck', ]) /** * Map the untrusted /live-sessions JSON body to snapshots. A non-array body * yields []; elements missing a string id or a valid status literal are dropped. */ export function mapLiveSessions(raw: unknown): SessionStatusSnapshot[] { if (!Array.isArray(raw)) return [] const snapshots: SessionStatusSnapshot[] = [] for (const element of raw) { const snapshot = toSnapshot(element) if (snapshot !== null) snapshots.push(snapshot) } return snapshots } /** Narrow one untrusted element to a snapshot, or null if it is invalid. */ function toSnapshot(element: unknown): SessionStatusSnapshot | null { if (!isRecord(element)) return null const { id, status, cwd, exited } = element if (typeof id !== 'string' || id === '') return null if (typeof status !== 'string' || !VALID_STATUSES.has(status)) return null // Drop already-exited sessions: the backend keeps them in list() until they // are reclaimed, but a post-exit status flip must not fire a stale "needs // attention" notification. if (exited === true) return null return { sessionId: id, claudeStatus: status as DesktopClaudeStatus, pendingApproval: false, gate: null, title: titleFromCwd(cwd), } } /** Last path segment of a cwd string, or undefined when cwd is absent/blank. * Splits on both separators so a Windows backslash path (an explicitly * supported target — shell.ts defaults to powershell.exe) yields the folder, * not the whole path. */ function titleFromCwd(cwd: unknown): string | undefined { if (typeof cwd !== 'string' || cwd === '') return undefined const segments = cwd.split(/[\\/]/).filter((segment) => segment !== '') const last = segments[segments.length - 1] return last === undefined ? undefined : last } /** Type guard: value is a non-null plain object (indexable by string). */ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) }