import { truncateUtf8 } from "./output.js"; import type { SubagentSessionSnapshot } from "./subagent-manager.js"; import { STATUS_ACTIVITY_LIMIT, type AccessMode, type LaunchThinkingSource, type SessionState, type ThinkingLevel, type UsageStats, type WorkState } from "./types.js"; export { STATUS_ACTIVITY_LIMIT } from "./types.js"; export const STATUS_JOB_LIMIT = 20; export const STATUS_PREVIEW_MAX_BYTES = 512; export const STATUS_PREVIEW_MAX_GRAPHEMES = 160; const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); export function sanitizeTerminalText(text: string, preserveSgr = false): string { let safe = ""; for (let index = 0; index < text.length;) { const code = text.charCodeAt(index); const escapeSequence = code === 0x1b; const osc = code === 0x9d || (escapeSequence && text[index + 1] === "]"); if (osc) { index += code === 0x9d ? 1 : 2; while ( index < text.length && text.charCodeAt(index) !== 0x07 && text.charCodeAt(index) !== 0x9c && !(text.charCodeAt(index) === 0x1b && text[index + 1] === "\\") ) index += 1; if (text.charCodeAt(index) === 0x1b) index += 2; else if (index < text.length) index += 1; continue; } const csi = code === 0x9b || (escapeSequence && text[index + 1] === "["); if (csi) { let end = index + (code === 0x9b ? 1 : 2); while (end < text.length && !(text.charCodeAt(end) >= 0x40 && text.charCodeAt(end) <= 0x7e)) end += 1; const sequence = text.slice(index, Math.min(text.length, end + 1)); const final = text[end]; const parameters = text.slice(index + (code === 0x9b ? 1 : 2), end); if (preserveSgr && final === "m" && /^[0-9:;]*$/.test(parameters)) safe += sequence; index = Math.min(text.length, end + 1); continue; } if (code === 0x1b) { index += Math.min(2, text.length - index); continue; } const value = text[index] ?? ""; if (value === "\n") safe += value; else if (value === "\t") safe += " "; else if (value !== "\r" && code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f)) safe += value; index += 1; } return safe; } export function boundedPreview(text: string, maxBytes = STATUS_PREVIEW_MAX_BYTES, maxGraphemes = STATUS_PREVIEW_MAX_GRAPHEMES): string { const normalized = sanitizeTerminalText(text).replace(/\s+/gu, " ").trim(); let bounded = ""; let count = 0; for (const segment of graphemes.segment(normalized)) { if (count >= maxGraphemes) break; bounded += segment.segment; count += 1; } return truncateUtf8(bounded, maxBytes).text; } export interface StatusActivity { timestamp: number; kind: "tool" | "diagnostic" | "model" | "progress" | "help"; summary: string; } export type StatusState = "queued" | "running" | "completed" | "failed" | "cancelled"; export interface JobStatus { id: string; state: StatusState; sessionState: SessionState; generationNumber?: number; workState?: WorkState; queued: boolean; queuedFollowUp: boolean; blockedByResult: boolean; unreadReportCount: number; omittedReports: number; /** The sole report text status may expose, terminal-safe and bounded. */ pendingHelpQuestion?: string; task: string; agent: string; access: AccessMode; createdAt: number; startedAt?: number; finishedAt?: number; queueDurationMs?: number; runDurationMs?: number; launchModel?: string; launchThinking?: string; reportedModel?: string; usage: UsageStats; recentActivity: StatusActivity[]; resultReady: boolean; /** Bounded terminal-safe output metadata for a collected generation. */ resultPreview?: string; hasError: boolean; } export interface StatusListResult { statuses: JobStatus[]; omitted: number; } const clampDuration = (end: number, start: number): number => Math.max(0, end - start); type LaunchSelection = { launchThinkingLevel?: ThinkingLevel; launchThinkingSource?: LaunchThinkingSource; }; const thinkingSourceText = (source: LaunchThinkingSource | undefined): string => { switch (source) { case "job": return "job override"; case "profile": return "profile"; case "parent": return "parent session"; default: return "model or Pi default"; } }; const thinkingSelection = (selection: Readonly): string => { const source = thinkingSourceText(selection.launchThinkingSource); if (!selection.launchThinkingLevel) return source; return `${selection.launchThinkingLevel} (${source})`; }; const emptyUsage = (): UsageStats => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }); const isSettledWork = (state: WorkState): boolean => state === "completed" || state === "failed" || state === "cancelled"; const statusState = (session: Readonly, workState: WorkState | undefined): StatusState => { if (session.state === "failed" || workState === "failed") return "failed"; if (session.state === "closed" || workState === "cancelled") return "cancelled"; if (workState === "queued" || session.state === "opening") return "queued"; if (workState === "running" || workState === "cancelling" || workState === "waiting_for_parent") return "running"; return "completed"; }; const queueEndTime = ( generation: SubagentSessionSnapshot["generation"] | undefined, workState: WorkState | undefined, now: number, ): number | undefined => { if (workState === "queued") return now; if (generation?.startedAt !== undefined) return generation.startedAt; if (workState !== undefined && isSettledWork(workState)) return generation?.finishedAt; return undefined; }; const runEndTime = ( generation: SubagentSessionSnapshot["generation"] | undefined, workState: WorkState | undefined, now: number, ): number | undefined => { if (workState === "queued") return undefined; if (workState !== undefined && !isSettledWork(workState)) return now; return generation?.finishedAt; }; const projectReportActivity = ( reports: Readonly, generationNumber: number | undefined, ): StatusActivity[] => { const activity: StatusActivity[] = []; for (const report of reports) { if (report.generation !== generationNumber) continue; activity.push({ timestamp: report.timestamp, kind: report.kind === "help_request" ? "help" : "progress", // Report bodies belong to subagent_inbox, not status. summary: report.kind === "help_request" ? "Help requested" : "Progress reported", }); } return activity; }; const projectSessionStatus = (session: Readonly, now: number): JobStatus => { // Snapshots deliberately omit results and live telemetry. Status must not infer either from private captures. const generation = session.generation as SubagentSessionSnapshot["generation"] | undefined; const workState = generation?.state; const queueEnd = queueEndTime(generation, workState, now); const runEnd = runEndTime(generation, workState, now); const safeActivity = (generation?.activity ?? []).flatMap((item): StatusActivity[] => { const summary = boundedPreview(item.text); return summary ? [{ timestamp: item.timestamp, kind: item.type, summary }] : []; }); const reportActivity = projectReportActivity(session.reports, generation?.number); const recentActivity = [...safeActivity, ...reportActivity] .sort((left, right) => left.timestamp - right.timestamp) .slice(-STATUS_ACTIVITY_LIMIT); return { id: boundedPreview(session.id), state: statusState(session, workState), sessionState: session.state, generationNumber: generation?.number, workState, queued: workState === "queued", queuedFollowUp: session.queuedFollowUp, blockedByResult: session.blockedByResult, unreadReportCount: session.reports.length, omittedReports: session.omittedReports, pendingHelpQuestion: session.pendingHelpQuestion && boundedPreview(session.pendingHelpQuestion), // The request task is a child prompt, so status exposes only a fixed placeholder. task: "Task details withheld.", agent: boundedPreview(session.profile.name), access: session.request.writeAccess ? "write" : "read-only", createdAt: session.createdAt, startedAt: generation?.startedAt, finishedAt: generation?.finishedAt, queueDurationMs: generation === undefined || queueEnd === undefined ? undefined : clampDuration(queueEnd, generation.createdAt), runDurationMs: generation?.startedAt === undefined || runEnd === undefined ? undefined : clampDuration(runEnd, generation.startedAt), launchModel: session.launchModel && boundedPreview(session.launchModel), launchThinking: thinkingSelection(session), usage: structuredClone(generation?.usage ?? emptyUsage()), reportedModel: generation?.reportedModel && boundedPreview(generation.reportedModel), recentActivity, resultReady: generation?.resultState === "ready", resultPreview: generation?.resultState === "collected" && generation.resultPreview ? boundedPreview(generation.resultPreview) : undefined, hasError: session.state === "failed" || workState === "failed", }; }; export function projectJobStatus(session: Readonly, now: number): JobStatus { return projectSessionStatus(session, now); } const statusGroup = (status: JobStatus): number => { if (status.sessionState === "opening" || (status.sessionState === "open" && status.workState !== undefined && !isSettledWork(status.workState))) return 0; if (status.resultReady || status.unreadReportCount > 0) return 1; return 2; }; export function selectStatusList(items: readonly SubagentSessionSnapshot[], now: number): StatusListResult { const statuses = items .map((item, index) => ({ index, status: projectJobStatus(item, now) })) .sort((left, right) => statusGroup(left.status) - statusGroup(right.status) || left.index - right.index) .map(({ status }) => status); return { statuses: statuses.slice(0, STATUS_JOB_LIMIT), omitted: Math.max(0, statuses.length - STATUS_JOB_LIMIT) }; } const durationText = (durationMs: number): string => { const seconds = Math.floor(Math.max(0, durationMs) / 1_000); return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; }; const durationDescription = (status: JobStatus): string | undefined => { if (status.state === "queued" && status.queueDurationMs !== undefined) return `queued for ${durationText(status.queueDurationMs)}`; if (status.state === "running" && status.runDurationMs !== undefined) return `running for ${durationText(status.runDurationMs)}`; if (status.runDurationMs !== undefined) return `ran for ${durationText(status.runDurationMs)}`; if (status.queueDurationMs !== undefined) return `queued for ${durationText(status.queueDurationMs)}`; return undefined; }; const activityAge = (timestamp: number, now: number): string => { const age = clampDuration(now, timestamp); return age < 1_000 ? "now" : `${durationText(age)} ago`; }; const usageText = (usage: UsageStats): string => `input ${usage.input}, output ${usage.output}, cache read ${usage.cacheRead}, cache write ${usage.cacheWrite}, cost ${usage.cost}, turns ${usage.turns}`; export function formatSingleJobStatus(status: JobStatus, now: number): string { const duration = durationDescription(status); const lines = [ `${status.id} — ${status.state}${duration ? ` · ${duration}` : ""}`, `Task: ${status.task}`, `Agent: ${status.agent} · Access: ${status.access}`, `Launch model: ${status.launchModel ?? "model or Pi default"}`, `Launch thinking: ${status.launchThinking ?? "model or Pi default"}`, ...(status.reportedModel && status.reportedModel !== status.launchModel ? [`Reported model: ${status.reportedModel}`] : []), `Usage: ${usageText(status.usage)}`, `Session: ${status.sessionState}${status.generationNumber === undefined ? "" : ` · Generation: ${status.generationNumber}`}${status.workState === undefined ? "" : ` · Work: ${status.workState}`}`, `Queued: ${status.queued ? "yes" : "no"} · Queued follow-up: ${status.queuedFollowUp ? "yes" : "no"} · Blocked by result: ${status.blockedByResult ? "yes" : "no"} · Unread reports: ${status.unreadReportCount}`, ...(status.omittedReports ? [`Reports omitted: ${status.omittedReports}`] : []), ...(status.pendingHelpQuestion ? [`Pending help: ${status.pendingHelpQuestion}`] : []), ...(status.resultPreview ? [`Result preview: ${status.resultPreview}`] : []), ...(status.recentActivity.length ? ["Recent activity:", ...status.recentActivity.map((item) => ` ${activityAge(item.timestamp, now)} ${item.summary}`)] : ["Recent activity: No activity reported yet"]), ...(status.hasError ? ["Error reported."] : []), ...(status.resultReady ? [`Result ready — collect ${status.id} to read it.`] : []), ]; return lines.join("\n"); } export function formatJobStatusList(result: StatusListResult, now: number): string { const lines = result.statuses.flatMap((status) => { const duration = durationDescription(status); const activity = status.recentActivity.at(-1); return [ `${status.id} — ${status.state}${duration ? ` · ${duration}` : ""} · ${status.task}`, activity ? ` ${activityAge(activity.timestamp, now)} ${activity.summary}` : " No activity reported yet", ]; }); if (result.omitted) lines.push(`${result.omitted} additional job${result.omitted === 1 ? "" : "s"} omitted.`); return lines.length ? lines.join("\n") : "No jobs."; }