import { TaskStatus, TERMINAL_STATUSES } from '../task/task-state.js'; import type { TaskState, PendingQuestion } from '../task/task-state.js'; import { mapToDisplay } from '../task/wire-state-mapper.js'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PROMPT_MAX_LEN = 50; const SUMMARY_LOG_LINES = 20; const DETAIL_OUTPUT_LINES = 10; // --------------------------------------------------------------------------- // Sorting helpers // --------------------------------------------------------------------------- /** Active statuses that should appear first on the scoreboard. */ const ACTIVE_STATUSES: ReadonlySet = new Set([ TaskStatus.RUNNING, TaskStatus.WAITING_ANSWER, TaskStatus.RATE_LIMITED, ]); /** Pending statuses that appear between active and terminal. */ const PENDING_STATUSES: ReadonlySet = new Set([ TaskStatus.WAITING, TaskStatus.PENDING, ]); function sortPriority(status: TaskStatus): number { if (ACTIVE_STATUSES.has(status)) return 0; if (PENDING_STATUSES.has(status)) return 1; return 2; // terminal } // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- function truncatePrompt(prompt: string, maxLen: number = PROMPT_MAX_LEN): string { if (prompt.length <= maxLen) return prompt; const cutoff = maxLen - 3; const lastSpace = prompt.lastIndexOf(' ', cutoff); const breakAt = lastSpace > 0 ? lastSpace : cutoff; return prompt.slice(0, breakAt).trimEnd() + '...'; } function formatElapsed(createdAt: string, updatedAt: string): string { const start = new Date(createdAt).getTime(); const end = new Date(updatedAt).getTime(); const diffMs = Math.max(0, end - start); const totalSec = Math.floor(diffMs / 1000); const min = Math.floor(totalSec / 60); const sec = totalSec % 60; if (min > 0) { return `${min}m ${sec}s`; } return `${sec}s`; } /** * Build a status-count summary string like "3 done, 1 busy, 1 wait". * Uses the display badge labels (without brackets) as category names. */ function statusSummary(tasks: TaskState[]): string { const counts = new Map(); for (const t of tasks) { const badge = mapToDisplay(t.status); // Strip brackets: "[busy]" → "busy" const label = badge.slice(1, -1); counts.set(label, (counts.get(label) ?? 0) + 1); } return Array.from(counts.entries()) .map(([label, count]) => `${count} ${label}`) .join(', '); } // --------------------------------------------------------------------------- // renderScoreboard // --------------------------------------------------------------------------- /** * Compact all-tasks view. * * Resource URI: `task:///all` */ export function renderScoreboard(tasks: TaskState[]): string { const sorted = [...tasks].sort((a, b) => sortPriority(a.status) - sortPriority(b.status)); const summary = tasks.length > 0 ? ` (${statusSummary(tasks)})` : ''; const header = `tasks -- ${tasks.length} total${summary}`; const lines: string[] = [header, '']; for (const task of sorted) { const badge = mapToDisplay(task.status); const prompt = truncatePrompt(task.prompt); const elapsed = formatElapsed(task.createdAt, task.updatedAt); const labelTag = task.labels.length > 0 ? ` [${task.labels.join(',')}]` : ''; lines.push(`${badge} ${task.id}${labelTag} -- "${prompt}" (${elapsed})`); } // Footer with quick-reference instructions lines.push(''); lines.push('> Details: read `task:///` · Events: read `task:////events` · Poll: read `task:///all` every ~30s'); // Pending Questions section const tasksWithQuestions = tasks.filter(t => t.pendingQuestions.length > 0); if (tasksWithQuestions.length > 0) { lines.push(''); lines.push(`## Pending Questions (${tasksWithQuestions.length})`); for (const task of tasksWithQuestions) { const pq = task.pendingQuestions[0]!; lines.push(''); lines.push(`### ${task.id}`); lines.push(''); if (pq.type === 'user_input') { for (const q of pq.questions) { lines.push(`**Q:** ${q.text}`); if (q.options && q.options.length > 0) { for (let i = 0; i < q.options.length; i++) { lines.push(` ${i + 1}. ${q.options[i]}`); } } } const answersExample: Record = {}; for (const q of pq.questions) { answersExample[q.id] = q.options?.length ? '1' : 'your answer'; } lines.push(''); lines.push(`Answer: \`respond-task ${JSON.stringify({ task_id: task.id, type: 'user_input', answers: answersExample })}\``); } else { lines.push(formatPendingQuestion(pq)); lines.push(''); lines.push(`Answer: \`respond-task { "task_id": "${task.id}", "type": "${pq.type}", ... }\``); } } } return lines.join('\n'); } // --------------------------------------------------------------------------- // renderTaskDetail // --------------------------------------------------------------------------- /** * Full task detail view as markdown. * * Resource URI: `task:///{id}` */ export function renderTaskDetail(task: TaskState): string { const badge = mapToDisplay(task.status); const lines: string[] = [ `# Task: ${task.id} -- ${truncatePrompt(task.prompt)}`, '', '| Field | Value |', '|---|---|', `| **Status** | ${badge} \`${task.status}\` |`, `| **Provider** | ${task.provider} |`, ]; if (task.sessionId) { lines.push(`| **Session ID** | \`${task.sessionId}\` |`); } if (task.model) { const reasoningCell = task.effort ? `\`${task.model}(${task.effort})\`` : `\`${task.model}\``; lines.push(`| **Reasoning** | ${reasoningCell} |`); } lines.push(`| **Task type** | ${task.taskType} |`); lines.push(`| **CWD** | \`${task.cwd}\` |`); lines.push(`| **Created** | ${task.createdAt} |`); if (task.startedAt) { lines.push(`| **Started** | ${task.startedAt} |`); } if (task.completedAt) { lines.push(`| **Completed** | ${task.completedAt} |`); } lines.push(`| **Updated** | ${task.updatedAt} |`); lines.push(''); // Pending questions — enhanced ACTION REQUIRED block with exact JSON examples if (task.pendingQuestions.length > 0) { lines.push('## ACTION REQUIRED — Agent is paused', ''); for (const pq of task.pendingQuestions) { if (pq.type === 'user_input') { for (let i = 0; i < pq.questions.length; i++) { const q = pq.questions[i]!; lines.push(`### Q${i + 1} [${q.id}] — ${q.text}`); if (q.options && q.options.length > 0) { for (let j = 0; j < q.options.length; j++) { lines.push(` ${j + 1}. **${q.options[j]}**`); } } lines.push(''); } // Build concrete call example const answersExample: Record = {}; for (const q of pq.questions) { answersExample[q.id] = q.options?.length ? '1' : 'YOUR_ANSWER'; } lines.push('### How to answer', ''); lines.push('You **MUST** call the `respond-task` tool now:', ''); lines.push('```json'); lines.push(JSON.stringify({ task_id: task.id, type: 'user_input', answers: answersExample }, null, 2)); lines.push('```', ''); lines.push('Answer formats: `"N"` select by number · `"N: detail"` select + context · `"OTHER: text"` freeform', ''); } else if (pq.type === 'command_approval') { lines.push(`**Command approval:** \`${pq.command}\``, ''); lines.push('```json'); lines.push(JSON.stringify({ task_id: task.id, type: 'command_approval', decision: 'accept' }, null, 2)); lines.push('```', ''); } else if (pq.type === 'file_approval') { lines.push(`**File approval:** ${pq.fileChanges.map(f => f.path).join(', ')}`, ''); lines.push('```json'); lines.push(JSON.stringify({ task_id: task.id, type: 'file_approval', decision: 'accept' }, null, 2)); lines.push('```', ''); } else if (pq.type === 'elicitation') { lines.push(`**Elicitation from "${pq.serverName ?? 'unknown'}":** ${pq.message}`, ''); lines.push('```json'); lines.push(JSON.stringify({ task_id: task.id, type: 'elicitation', action: 'accept' }, null, 2)); lines.push('```', ''); } else if (pq.type === 'dynamic_tool') { lines.push(`**Dynamic tool:** \`${pq.toolName}\``, ''); lines.push('```json'); lines.push(JSON.stringify({ task_id: task.id, type: 'dynamic_tool', result: 'your result' }, null, 2)); lines.push('```', ''); } } } // Error if (task.error) { lines.push('## Error', '', `\`\`\`\n${task.error}\n\`\`\``, ''); } // Last N output lines if (task.output.length > 0) { const tail = task.output.slice(-DETAIL_OUTPUT_LINES); lines.push('## Recent Output', ''); for (const line of tail) { lines.push(line); } lines.push(''); } return lines.join('\n'); } function formatPendingQuestion(q: PendingQuestion): string { switch (q.type) { case 'user_input': return q.questions.map((iq) => `- **${iq.text}**${iq.options ? ` (options: ${iq.options.join(', ')})` : ''}`).join('\n'); case 'command_approval': return `- **Command approval**: \`${q.command}\``; case 'file_approval': return `- **File approval**: ${q.fileChanges.map((f) => f.path).join(', ')}`; case 'elicitation': return `- **Elicitation**: ${q.message}`; case 'dynamic_tool': return `- **Dynamic tool**: \`${q.toolName}\``; } } // --------------------------------------------------------------------------- // renderSummaryLog // --------------------------------------------------------------------------- /** * Last 20 lines from task.output. * * Resource URI: `task:///{id}/log` */ export function renderSummaryLog(task: TaskState): string { if (task.output.length === 0) { return `# Log: ${task.id}\n\nNo output yet.`; } const tail = task.output.slice(-SUMMARY_LOG_LINES); const header = `# Log: ${task.id} (last ${tail.length} of ${task.output.length} lines)`; return [header, '', ...tail].join('\n'); } // --------------------------------------------------------------------------- // renderVerboseLog // --------------------------------------------------------------------------- /** * All lines from task.output (no truncation). * * Resource URI: `task:///{id}/log.verbose` */ export function renderVerboseLog(task: TaskState): string { if (task.output.length === 0) { return `# Verbose Log: ${task.id}\n\nNo output yet.`; } const header = `# Verbose Log: ${task.id} (${task.output.length} lines)`; return [header, '', ...task.output].join('\n'); }