From 1ec68cc038ad4fec21b4cc9f95031b7a6789c7b0 Mon Sep 17 00:00:00 2001 From: pufit Date: Mon, 25 May 2026 19:58:32 -0400 Subject: [PATCH 1/3] =?UTF-8?q?compat:=20handle=20claude=20code=202.1.x=20?= =?UTF-8?q?tool=20renames=20(Task=E2=86=92Agent,=20new=20Task*=20tools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code 2.1.150 renamed the subagent-spawning tool from `Task` to `Agent`, added a per-session task-tracking suite (`TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet`, `TaskStop`, `TaskOutput`) that replaces `TodoWrite`, and introduced `ScheduleWakeup` for `/loop` dynamic mode. Without changes: - Sub-agent panels stopped opening (engine + UI only matched `Task`). - New `Task*` tool calls fell through to the generic block with no context-aware rendering. - The model could call `ScheduleWakeup`, which Nerve has no `/loop` skill to consume — dead-end turns. Backend: - Set `disallowed_tools=["ScheduleWakeup"]` so the CLI strips it from the model's tool list entirely. - Track sub-agent lifecycle for both `Task` (history back-compat) and `Agent` (current). Frontend: - Route `Agent` to `SubagentToolBlock` (keep `Task` for old history). - New `CCTaskToolBlock` renders the six `Task*` tools as compact cards. - `TodoPanel` now also displays Claude Code 2.1+ tasks (with id badges), reconciled live by an incremental parser in `helpers/ccTasks.ts` (TaskCreate input → placeholder row, TaskUpdate optimistic mutate, TaskList result → replace, TaskGet → upsert). - `chatStore` gains `currentCCTasks` state, restored on session switch and on mid-turn buffer replay. - `bufferReplay` recognises `Agent` for panel rebuild and exports history/buffer task extractors. - `BackgroundJobs` shows the Bot icon for both `Agent` and `Task`. --- nerve/agent/engine.py | 17 +- web/src/components/Chat/BackgroundJobs.tsx | 4 +- web/src/components/Chat/TodoPanel.tsx | 86 +++++-- web/src/components/Chat/ToolCallBlock.tsx | 13 + .../components/Chat/tools/CCTaskToolBlock.tsx | 133 ++++++++++ web/src/pages/ChatPage.tsx | 4 +- web/src/stores/chatStore.ts | 28 ++- web/src/stores/handlers/sessionHandlers.ts | 7 +- web/src/stores/handlers/streamingHandlers.ts | 64 ++++- web/src/stores/helpers/bufferReplay.ts | 105 +++++++- web/src/stores/helpers/ccTasks.ts | 234 ++++++++++++++++++ 11 files changed, 660 insertions(+), 35 deletions(-) create mode 100644 web/src/components/Chat/tools/CCTaskToolBlock.tsx create mode 100644 web/src/stores/helpers/ccTasks.ts diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index d0dafd0..93e6c24 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -883,6 +883,12 @@ def _cli_stderr(line: str) -> None: # No allowed_tools — can_use_tool callback handles permissions. # External MCP server tools are discovered at connection time, # so we can't enumerate them upfront. + # ``disallowed_tools`` strips tools from the model's tool list + # entirely (cleaner than runtime denial). ``ScheduleWakeup`` is a + # Claude Code built-in for ``/loop`` dynamic mode — Nerve has no + # ``/loop`` skill, so the tool would be a dead end. Removing it + # avoids the model wasting turns calling it. + disallowed_tools=["ScheduleWakeup"], env=self._build_env(), cwd=str(self.config.workspace), mcp_servers=self._build_mcp_servers(session_id), @@ -1908,8 +1914,15 @@ async def _image_prompt(): tool_use_id=tool_use_id, parent_tool_use_id=parent_id, ) - # Track sub-agent lifecycle - if tool_name == "Task" and tool_use_id: + # Track sub-agent lifecycle. Claude Code + # 2.1.x renamed the subagent-spawning + # tool from ``Task`` → ``Agent`` (and + # introduced separate ``TaskCreate``/ + # ``TaskUpdate``/etc. tools for in- + # session todo tracking). Match both + # names so old session history still + # opens panels on replay. + if tool_name in ("Task", "Agent") and tool_use_id: active_subagents[tool_use_id] = asyncio.get_event_loop().time() await broadcaster.broadcast_subagent_start( session_id, diff --git a/web/src/components/Chat/BackgroundJobs.tsx b/web/src/components/Chat/BackgroundJobs.tsx index b557710..f24daa4 100644 --- a/web/src/components/Chat/BackgroundJobs.tsx +++ b/web/src/components/Chat/BackgroundJobs.tsx @@ -11,9 +11,9 @@ function formatElapsed(startedAt: number): string { return `${min}m ${sec % 60}s`; } -/** Icon for task tool type. */ +/** Icon for task tool type. Claude Code 2.1.x renamed Task → Agent. */ function toolIcon(tool: string) { - if (tool === 'Task') return Bot; + if (tool === 'Agent' || tool === 'Task') return Bot; return Terminal; } diff --git a/web/src/components/Chat/TodoPanel.tsx b/web/src/components/Chat/TodoPanel.tsx index 15a433b..79e5248 100644 --- a/web/src/components/Chat/TodoPanel.tsx +++ b/web/src/components/Chat/TodoPanel.tsx @@ -1,13 +1,62 @@ import { useEffect, useRef, useState } from 'react'; import { CheckCircle2, Circle, Loader2 } from 'lucide-react'; -import type { TodoItem } from '../../stores/chatStore'; +import type { TodoItem, CCTask } from '../../stores/chatStore'; -export function TodoPanel({ todos }: { todos: TodoItem[] }) { +/** + * Unified row model rendered by the panel. Both legacy TodoWrite items + * and Claude Code 2.1+ tasks collapse into this shape so the panel only + * needs one renderer. + */ +interface PanelRow { + key: string; + label: string; + activeLabel: string; + status: 'pending' | 'in_progress' | 'completed'; + /** Numeric task id ("1", "2", ...) — only set for CC tasks; shown as a badge. */ + id?: string; +} + +function ccTaskToRow(task: CCTask, index: number): PanelRow { + // Skip the placeholder prefix in the badge — show "—" while we wait. + const badge = task.id.startsWith('pending:') ? undefined : task.id; + return { + key: `cc:${task.id || index}`, + label: task.subject, + activeLabel: task.activeForm || task.subject, + status: task.status, + id: badge, + }; +} + +function todoToRow(todo: TodoItem, index: number): PanelRow { + return { + key: `todo:${index}:${todo.content}`, + label: todo.content, + activeLabel: todo.activeForm || todo.content, + status: todo.status, + }; +} + +export function TodoPanel({ + todos, + ccTasks, +}: { + todos: TodoItem[]; + ccTasks?: CCTask[]; +}) { const [visible, setVisible] = useState(true); const hideTimer = useRef | null>(null); - const prevTodosRef = useRef([]); + // Track previous row count so we can re-show the panel when new work appears. + const prevCountRef = useRef(0); + + // CC tasks supersede legacy TodoWrite in Claude Code 2.1+, but a session + // mid-migration could have both. Show whichever has rows; if both, show + // CC tasks (the modern source of truth). + const rows: PanelRow[] = ccTasks && ccTasks.length > 0 + ? ccTasks.map(ccTaskToRow) + : todos.map(todoToRow); - const allDone = todos.length > 0 && todos.every(t => t.status === 'completed'); + const allDone = rows.length > 0 && rows.every(r => r.status === 'completed'); // Auto-hide 5s after all items complete useEffect(() => { @@ -27,15 +76,17 @@ export function TodoPanel({ todos }: { todos: TodoItem[] }) { }; }, [allDone]); - // Reset visibility when todos change from empty + // Reset visibility when row count grows from empty useEffect(() => { - if (prevTodosRef.current.length === 0 && todos.length > 0) { + if (prevCountRef.current === 0 && rows.length > 0) { setVisible(true); } - prevTodosRef.current = todos; - }, [todos]); + prevCountRef.current = rows.length; + }, [rows.length]); - if (todos.length === 0 || !visible) return null; + if (rows.length === 0 || !visible) return null; + + const completed = rows.filter(r => r.status === 'completed').length; return (
@@ -43,12 +94,12 @@ export function TodoPanel({ todos }: { todos: TodoItem[] }) {
Tasks - {todos.filter(t => t.status === 'completed').length}/{todos.length} + {completed}/{rows.length}
- {todos.map((todo, i) => ( - + {rows.map(row => ( + ))}
@@ -56,9 +107,9 @@ export function TodoPanel({ todos }: { todos: TodoItem[] }) { ); } -function TodoRow({ todo }: { todo: TodoItem }) { - const isCompleted = todo.status === 'completed'; - const isActive = todo.status === 'in_progress'; +function TaskRow({ row }: { row: PanelRow }) { + const isCompleted = row.status === 'completed'; + const isActive = row.status === 'in_progress'; return (
@@ -69,8 +120,11 @@ function TodoRow({ todo }: { todo: TodoItem }) { ) : ( )} + {row.id && ( + #{row.id} + )} - {isActive ? todo.activeForm : todo.content} + {isActive ? row.activeLabel : row.label}
); diff --git a/web/src/components/Chat/ToolCallBlock.tsx b/web/src/components/Chat/ToolCallBlock.tsx index 1f1ed02..b4f1542 100644 --- a/web/src/components/Chat/ToolCallBlock.tsx +++ b/web/src/components/Chat/ToolCallBlock.tsx @@ -8,6 +8,7 @@ import { BashToolBlock } from './tools/BashToolBlock'; import { FileToolBlock } from './tools/FileToolBlock'; import { MemoryToolBlock } from './tools/MemoryToolBlock'; import { TaskToolBlock } from './tools/TaskToolBlock'; +import { CCTaskToolBlock } from './tools/CCTaskToolBlock'; import { SourceToolBlock } from './tools/SourceToolBlock'; import { SubagentToolBlock } from './tools/SubagentToolBlock'; import { HoAToolBlock } from './tools/HoAToolBlock'; @@ -38,8 +39,20 @@ export function ToolCallBlock({ block }: { block: ToolCallBlockData }) { case 'Read': case 'Write': return ; + // Sub-agent spawn: Claude Code 2.1.x renamed "Task" → "Agent". Match both + // so old chat history keeps rendering with the sub-agent card. + case 'Agent': case 'Task': return ; + // Claude Code 2.1+ task tools (replacement for TodoWrite). Compact card + // in chat; full list shown by the TaskPanel below the message stream. + case 'TaskCreate': + case 'TaskUpdate': + case 'TaskList': + case 'TaskGet': + case 'TaskStop': + case 'TaskOutput': + return ; case 'AskUserQuestion': return ; case 'ExitPlanMode': diff --git a/web/src/components/Chat/tools/CCTaskToolBlock.tsx b/web/src/components/Chat/tools/CCTaskToolBlock.tsx new file mode 100644 index 0000000..fa4967d --- /dev/null +++ b/web/src/components/Chat/tools/CCTaskToolBlock.tsx @@ -0,0 +1,133 @@ +import { ChevronRight, ChevronDown, Plus, Pencil, ListTodo, FileText, OctagonX, Radio, Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import type { ToolCallBlockData } from '../../../types/chat'; +import { extractResultText } from '../../../utils/extractResultText'; + +/** + * Compact card for Claude Code 2.1+ task tools (TaskCreate / TaskUpdate / + * TaskList / TaskGet / TaskStop / TaskOutput). One-line by default; click + * to expand input + result. + * + * The real "what tasks are open" view lives in the TaskPanel below the + * message stream — these cards just acknowledge the call in the chat. + */ +export function CCTaskToolBlock({ block }: { block: ToolCallBlockData }) { + const [expanded, setExpanded] = useState(false); + const isRunning = block.status === 'running'; + const input = block.input || {}; + const resultText = block.result !== undefined ? extractResultText(block.result) : ''; + + let Icon = ListTodo; + let label = block.tool; + let summary = ''; + let iconColor = 'text-text-muted'; + + switch (block.tool) { + case 'TaskCreate': { + Icon = Plus; + label = 'Create task'; + summary = String(input.subject || ''); + iconColor = 'text-hue-blue'; + break; + } + case 'TaskUpdate': { + Icon = Pencil; + label = 'Update task'; + const id = String(input.taskId || ''); + const status = String(input.status || ''); + summary = id + ? `#${id}${status ? ' → ' + status : ''}` + : status; + iconColor = status === 'completed' ? 'text-hue-green' : 'text-hue-amber'; + break; + } + case 'TaskList': { + Icon = ListTodo; + label = 'List tasks'; + // Count the rendered "#N [status]" lines; fall back to "" so the + // summary stays empty rather than showing a misleading "0 tasks" + // while the call is still running. + if (resultText) { + if (/^\s*No tasks found\s*$/i.test(resultText)) { + summary = 'no tasks'; + } else { + const count = resultText.split('\n').filter(l => /^\s*#\d+\s+\[/.test(l)).length; + summary = count > 0 ? `${count} task${count === 1 ? '' : 's'}` : ''; + } + } + iconColor = 'text-text-muted'; + break; + } + case 'TaskGet': { + Icon = FileText; + label = 'Read task'; + const id = String(input.taskId || ''); + summary = id ? `#${id}` : ''; + iconColor = 'text-text-muted'; + break; + } + case 'TaskStop': { + Icon = OctagonX; + label = 'Stop task'; + summary = String(input.taskId || input.task_id || ''); + iconColor = 'text-hue-red'; + break; + } + case 'TaskOutput': { + Icon = Radio; + label = 'Task output'; + summary = String(input.taskId || input.task_id || ''); + iconColor = 'text-text-muted'; + break; + } + } + + return ( +
+ + + {expanded && ( +
+ {/* Input */} +
+
Input
+
+              {JSON.stringify(input, null, 2)}
+            
+
+ + {/* Result */} + {resultText && ( +
+
+ {block.isError ? 'Error' : 'Result'} +
+
+                {resultText}
+              
+
+ )} + + {isRunning && block.result === undefined && ( +
+ Running... +
+ )} +
+ )} +
+ ); +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 5ef54e1..43eff42 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -32,7 +32,7 @@ export function ChatPage() { const { sessions, activeSession, messages, streamingBlocks, isStreaming, loading, - agentStatus, contextUsage, currentTodos, + agentStatus, contextUsage, currentTodos, currentCCTasks, sidebarCollapsed, panels, modifiedFiles, modifiedFilesCount, loadSessions, switchSession, createSession, deleteSession, @@ -176,7 +176,7 @@ export function ChatPage() { /> )} - + / on the CLI side; tracked here + * so the in-chat "Tasks" panel reflects what the model is planning during the + * turn. Replaces the older TodoWrite todo list. + */ +export interface CCTask { + id: string; // numeric string assigned by the CLI ("1", "2", ...) + subject: string; // brief title + activeForm?: string; // present continuous, shown while in_progress + status: 'pending' | 'in_progress' | 'completed'; + owner?: string; + blockedBy?: string[]; +} + export type QuoteAction = 'add' | 'remove' | 'improve' | 'question' | 'note'; export interface QuoteEntry { @@ -59,8 +74,10 @@ interface ChatState { max_context_tokens: number; num_turns: number; } | null; - // TodoWrite panel state + // TodoWrite panel state (legacy Claude Code todos) currentTodos: TodoItem[]; + // Claude Code 2.1+ task panel state (TaskCreate / TaskUpdate / TaskList) + currentCCTasks: CCTask[]; // Text selection quotes quotes: QuoteEntry[]; @@ -135,6 +152,7 @@ export const useChatStore = create((set, get) => ({ agentStatus: { state: 'idle' }, contextUsage: null, currentTodos: [], + currentCCTasks: [], quotes: [], panels: [], activePanelId: null, @@ -318,7 +336,7 @@ export const useChatStore = create((set, get) => ({ set({ activeSession: id, messages: [], loading: true, streamingBlocks: [], isStreaming: false, agentStatus: { state: 'idle' }, contextUsage: null, - currentTodos: [], pendingInteraction: null, + currentTodos: [], currentCCTasks: [], pendingInteraction: null, panels: [], activePanelId: null, panelVisible: false, modifiedFiles: [], modifiedFilesCount: 0, backgroundTasks: [], }); @@ -346,8 +364,10 @@ export const useChatStore = create((set, get) => ({ num_turns: data.last_usage.num_turns || 1, }; } - // Restore todos from last TodoWrite call in history + // Restore todos from last TodoWrite call in history (legacy) update.currentTodos = extractTodosFromMessages(hydrated); + // Restore Claude Code 2.1+ task panel from history + update.currentCCTasks = extractCCTasksFromMessages(hydrated); set(update); // Fetch modified files for this session (non-blocking) get().fetchModifiedFiles(id); diff --git a/web/src/stores/handlers/sessionHandlers.ts b/web/src/stores/handlers/sessionHandlers.ts index 730c79f..25dc010 100644 --- a/web/src/stores/handlers/sessionHandlers.ts +++ b/web/src/stores/handlers/sessionHandlers.ts @@ -1,6 +1,6 @@ import type { WSMessage } from '../../api/websocket'; import type { PanelTab } from '../../types/chat'; -import { applyStreamEvent, rebuildPanelTabsFromBuffer, deriveStatus, extractTodosFromBuffer } from '../helpers/bufferReplay'; +import { applyStreamEvent, rebuildPanelTabsFromBuffer, deriveStatus, extractTodosFromBuffer, extractCCTasksFromBuffer } from '../helpers/bufferReplay'; import type { Get, Set } from './types'; // ------------------------------------------------------------------ // @@ -94,6 +94,8 @@ export function handleSessionStatus( // backgrounded) sees a stale snapshot from persisted history because the // buffered TodoWrite tool_use events fed only streamingBlocks. const restoredTodos = extractTodosFromBuffer(bufferedEvents); + // Same for Claude Code 2.1+ Task* tools. + const restoredCCTasks = extractCCTasksFromBuffer(bufferedEvents); const update: Record = { isStreaming: true, @@ -107,6 +109,9 @@ export function handleSessionStatus( if (restoredTodos !== null) { update.currentTodos = restoredTodos; } + if (restoredCCTasks !== null) { + update.currentCCTasks = restoredCCTasks; + } set(update); } else { set({ isStreaming: true, streamingBlocks: blocks, agentStatus: deriveStatus(blocks) }); diff --git a/web/src/stores/handlers/streamingHandlers.ts b/web/src/stores/handlers/streamingHandlers.ts index 58a69da..7713e2f 100644 --- a/web/src/stores/handlers/streamingHandlers.ts +++ b/web/src/stores/handlers/streamingHandlers.ts @@ -1,9 +1,32 @@ import type { WSMessage } from '../../api/websocket'; import { extractResultText } from '../../utils/extractResultText'; import { appendBlockToPanel, updateToolResultInPanel, scheduleAutoClose } from '../helpers/blockHelpers'; -import type { TodoItem } from '../chatStore'; +import type { TodoItem, CCTask } from '../chatStore'; +import { + applyCCTaskCreateInput, + applyCCTaskUpdateInput, + parseCCTaskListResult, + parseCCTaskGetResult, + parseCCTaskCreateResult, +} from '../helpers/ccTasks'; import type { Get, Set } from './types'; +/** Tool names that drive the Claude Code task panel (TaskCreate / TaskUpdate + * / TaskList / TaskGet). TaskStop / TaskOutput target background subagent + * jobs, not the task list, and are intentionally excluded. */ +const CC_TASK_TOOLS = new Set([ + 'TaskCreate', + 'TaskUpdate', + 'TaskList', + 'TaskGet', +]); + +/** Subagent-spawning tool name. Claude Code 2.1.x renamed this from "Task" + * → "Agent"; we match both so old chat history still opens panels. */ +function isSubagentTool(name: string | undefined): boolean { + return name === 'Agent' || name === 'Task'; +} + // ------------------------------------------------------------------ // // Streaming handlers: thinking, token, tool_use, tool_result // // ------------------------------------------------------------------ // @@ -61,8 +84,8 @@ export function handleToolUse( ): void { const state = get(); - // Is this a Task (sub-agent) call? - if (msg.tool === 'Task') { + // Is this a sub-agent spawn call? (Claude Code 2.1.x renamed Task → Agent) + if (isSubagentTool(msg.tool)) { const toolUseId = msg.tool_use_id || ''; // Add compact card to main chat const blocks = [...state.streamingBlocks]; @@ -121,6 +144,16 @@ export function handleToolUse( if (msg.tool === 'TodoWrite' && Array.isArray(msg.input?.todos)) { extraUpdate.currentTodos = msg.input.todos as TodoItem[]; } + // Optimistically reflect Claude Code task tool calls in the panel before + // the result arrives. TaskCreate adds a placeholder row (real ID lands on + // tool_result); TaskUpdate mutates by taskId so the row reacts instantly. + if (msg.tool === 'TaskCreate') { + const input = (msg.input ?? {}) as Record; + extraUpdate.currentCCTasks = applyCCTaskCreateInput(state.currentCCTasks, input, msg.tool_use_id || ''); + } else if (msg.tool === 'TaskUpdate') { + const input = (msg.input ?? {}) as Record; + extraUpdate.currentCCTasks = applyCCTaskUpdateInput(state.currentCCTasks, input); + } set({ streamingBlocks: blocks, agentStatus: { state: 'tool', toolName: msg.tool }, ...extraUpdate }); } } @@ -174,7 +207,30 @@ export function handleToolResult( } return b; }); - set({ streamingBlocks: blocks, agentStatus: { state: 'thinking' } }); + // Find the originating tool_use to know which CC task tool this result + // belongs to. The tool name doesn't ride on tool_result, so we have to + // look it up in the block we just updated. + const ccTaskUpdate: { currentCCTasks?: CCTask[] } = {}; + if (!msg.is_error && msg.tool_use_id) { + const sourceBlock = blocks.find( + b => b.type === 'tool_call' && b.toolUseId === msg.tool_use_id, + ); + const sourceTool = sourceBlock?.type === 'tool_call' ? sourceBlock.tool : undefined; + if (sourceTool && CC_TASK_TOOLS.has(sourceTool)) { + const resultText = extractResultText(msg.result); + let next: CCTask[] | null = null; + if (sourceTool === 'TaskList') { + next = parseCCTaskListResult(resultText, state.currentCCTasks); + } else if (sourceTool === 'TaskCreate') { + next = parseCCTaskCreateResult(resultText, state.currentCCTasks, msg.tool_use_id); + } else if (sourceTool === 'TaskGet') { + next = parseCCTaskGetResult(resultText, state.currentCCTasks); + } + // TaskUpdate result is opaque — we already applied input optimistically. + if (next) ccTaskUpdate.currentCCTasks = next; + } + } + set({ streamingBlocks: blocks, agentStatus: { state: 'thinking' }, ...ccTaskUpdate }); // Update matching panel tab (for non-sub-agent panels like plan_update) const matchingTab = state.panels.find(p => p.id === msg.tool_use_id); diff --git a/web/src/stores/helpers/bufferReplay.ts b/web/src/stores/helpers/bufferReplay.ts index a46e246..7fb86f6 100644 --- a/web/src/stores/helpers/bufferReplay.ts +++ b/web/src/stores/helpers/bufferReplay.ts @@ -1,7 +1,14 @@ import type { WSMessage } from '../../api/websocket'; import type { ChatMessage, MessageBlock, PanelTab, AgentStatus } from '../../types/chat'; import { extractResultText } from '../../utils/extractResultText'; -import type { TodoItem } from '../chatStore'; +import type { TodoItem, CCTask } from '../chatStore'; +import { + applyCCTaskCreateInput, + applyCCTaskUpdateInput, + parseCCTaskListResult, + parseCCTaskGetResult, + parseCCTaskCreateResult, +} from './ccTasks'; /** * Apply a single stream event to a blocks array (pure function for replay). @@ -74,9 +81,11 @@ export function rebuildPanelTabsFromBuffer( const panels: PanelTab[] = []; const panelMap = new Map(); - // First pass: create panel tabs for Task tool_use events + // First pass: create panel tabs for subagent-spawning tool_use events. + // Claude Code 2.1.x renamed the subagent tool from "Task" → "Agent"; we + // match both so old chat history still rebuilds panels correctly. for (const event of events) { - if (event.type === 'tool_use' && event.tool === 'Task') { + if (event.type === 'tool_use' && (event.tool === 'Agent' || event.tool === 'Task')) { const subagentType = String(event.input?.subagent_type || event.input?.model || 'agent'); const toolUseId = event.tool_use_id || ''; const block = blocks.find( @@ -201,10 +210,98 @@ export function extractTodosFromBuffer(events: WSMessage[]): TodoItem[] | null { const event = events[i]; if (event.type !== 'tool_use') continue; if (event.tool !== 'TodoWrite') continue; - // Sub-agent (Task) child TodoWrite calls belong to the panel, not the main todos. + // Sub-agent (Agent / Task) child TodoWrite calls belong to the panel, not the main todos. if ('parent_tool_use_id' in event && event.parent_tool_use_id) continue; const todos = (event.input as { todos?: TodoItem[] } | undefined)?.todos; if (Array.isArray(todos)) return todos as TodoItem[]; } return null; } + +/** True for Claude Code 2.1+ task tools that drive the task panel. */ +function isCCTaskTool(tool: string | undefined): boolean { + return tool === 'TaskCreate' + || tool === 'TaskUpdate' + || tool === 'TaskList' + || tool === 'TaskGet'; +} + +/** + * Replay every TaskCreate / TaskUpdate / TaskList / TaskGet call across the + * persisted message history to rebuild the current task panel. Walks + * forward so optimistic inputs and authoritative results compose in the + * same order they were emitted live. + * + * Sub-agent child task calls (parent_tool_use_id set) belong to the panel + * tab for that sub-agent, not the main chat — same convention as TodoWrite. + */ +export function extractCCTasksFromMessages(messages: ChatMessage[]): CCTask[] { + let tasks: CCTask[] = []; + for (const msg of messages) { + if (msg.role !== 'assistant') continue; + for (const block of msg.blocks) { + if (block.type !== 'tool_call') continue; + if (!isCCTaskTool(block.tool)) continue; + const input = (block.input ?? {}) as Record; + const toolUseId = block.toolUseId || ''; + // Apply input optimistically (mirrors live handler). + if (block.tool === 'TaskCreate') { + tasks = applyCCTaskCreateInput(tasks, input, toolUseId); + } else if (block.tool === 'TaskUpdate') { + tasks = applyCCTaskUpdateInput(tasks, input); + } + // Then reconcile with the result, if we have one and it's not an error. + if (block.status === 'complete' && !block.isError && block.result !== undefined) { + const resultText = extractResultText(block.result); + if (block.tool === 'TaskList') { + tasks = parseCCTaskListResult(resultText, tasks); + } else if (block.tool === 'TaskCreate') { + tasks = parseCCTaskCreateResult(resultText, tasks, toolUseId); + } else if (block.tool === 'TaskGet') { + tasks = parseCCTaskGetResult(resultText, tasks); + } + // TaskUpdate result is opaque; the input application above is enough. + } + } + } + return tasks; +} + +/** + * Buffer-side equivalent of extractCCTasksFromMessages — walks live WS + * events to rebuild the task panel on reconnect. Returns null when no + * relevant events are present so the caller can keep whatever was already + * restored from persisted history. + */ +export function extractCCTasksFromBuffer(events: WSMessage[]): CCTask[] | null { + let saw = false; + let tasks: CCTask[] = []; + // Track tool_use_id → tool name so we can dispatch on tool_result. + const toolByUseId = new Map(); + for (const event of events) { + if ('parent_tool_use_id' in event && event.parent_tool_use_id) continue; + if (event.type === 'tool_use' && isCCTaskTool(event.tool)) { + saw = true; + const input = (event.input ?? {}) as Record; + const toolUseId = event.tool_use_id || ''; + toolByUseId.set(toolUseId, event.tool); + if (event.tool === 'TaskCreate') { + tasks = applyCCTaskCreateInput(tasks, input, toolUseId); + } else if (event.tool === 'TaskUpdate') { + tasks = applyCCTaskUpdateInput(tasks, input); + } + } else if (event.type === 'tool_result' && event.tool_use_id) { + const sourceTool = toolByUseId.get(event.tool_use_id); + if (!sourceTool || event.is_error) continue; + const resultText = extractResultText(event.result); + if (sourceTool === 'TaskList') { + tasks = parseCCTaskListResult(resultText, tasks); + } else if (sourceTool === 'TaskCreate') { + tasks = parseCCTaskCreateResult(resultText, tasks, event.tool_use_id); + } else if (sourceTool === 'TaskGet') { + tasks = parseCCTaskGetResult(resultText, tasks); + } + } + } + return saw ? tasks : null; +} diff --git a/web/src/stores/helpers/ccTasks.ts b/web/src/stores/helpers/ccTasks.ts new file mode 100644 index 0000000..0a046a9 --- /dev/null +++ b/web/src/stores/helpers/ccTasks.ts @@ -0,0 +1,234 @@ +/** + * Helpers for Claude Code 2.1.x task tools (TaskCreate / TaskUpdate / + * TaskList / TaskGet). + * + * The CLI stores tasks per-session in ~/.claude/tasks// as JSON files. + * Nerve doesn't reach into that directory — it just mirrors what flows + * through tool calls and results so the in-chat panel reflects the current + * todo list. + * + * Result content is plain text (the CLI's mapToolResultToToolResultBlockParam): + * - TaskCreate: "Task #N created successfully: SUBJECT" + * - TaskList: "#N [STATUS] SUBJECT [(OWNER)] [[blocked by #X, #Y]]" + * one per line, or "No tasks found" + * - TaskGet: multi-line: + * Task #N: SUBJECT + * Status: STATUS + * Description: ... + * [Blocked by: #X, #Y] + * [Blocks: #X, #Y] + * - TaskUpdate: shape varies; we apply the input optimistically instead. + */ +import type { CCTask } from '../chatStore'; + +type Status = CCTask['status']; + +const STATUSES: ReadonlySet = new Set([ + 'pending', + 'in_progress', + 'completed', +]); + +function asStatus(s: unknown): Status | undefined { + return typeof s === 'string' && STATUSES.has(s as Status) + ? (s as Status) + : undefined; +} + +/** Stable id used for an optimistic TaskCreate row before the CLI assigns one. */ +function placeholderId(toolUseId: string): string { + return `pending:${toolUseId}`; +} + +/** Sort by numeric id ascending, with placeholder rows last. */ +function sortTasks(tasks: CCTask[]): CCTask[] { + return [...tasks].sort((a, b) => { + const aIsPlaceholder = a.id.startsWith('pending:'); + const bIsPlaceholder = b.id.startsWith('pending:'); + if (aIsPlaceholder && !bIsPlaceholder) return 1; + if (!aIsPlaceholder && bIsPlaceholder) return -1; + const na = Number(a.id); + const nb = Number(b.id); + if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb; + return a.id.localeCompare(b.id); + }); +} + +/** + * Add a placeholder row for an in-flight TaskCreate. The real numeric ID + * lands on tool_result and is reconciled by `parseCCTaskCreateResult`. + */ +export function applyCCTaskCreateInput( + current: CCTask[], + input: Record, + toolUseId: string, +): CCTask[] { + const subject = typeof input.subject === 'string' ? input.subject : ''; + if (!subject) return current; + const activeForm = typeof input.activeForm === 'string' ? input.activeForm : undefined; + const id = placeholderId(toolUseId); + // If we somehow already have a row for this toolUseId, no-op. + if (current.some(t => t.id === id)) return current; + return sortTasks([ + ...current, + { id, subject, activeForm, status: 'pending' }, + ]); +} + +/** Apply a TaskUpdate input optimistically. */ +export function applyCCTaskUpdateInput( + current: CCTask[], + input: Record, +): CCTask[] { + const taskId = typeof input.taskId === 'string' ? input.taskId : ''; + if (!taskId) return current; + const status = asStatus(input.status); + const subject = typeof input.subject === 'string' ? input.subject : undefined; + const activeForm = typeof input.activeForm === 'string' ? input.activeForm : undefined; + const owner = typeof input.owner === 'string' ? input.owner : undefined; + // Status === "deleted" removes the row. + if (input.status === 'deleted') { + return current.filter(t => t.id !== taskId); + } + let found = false; + const next = current.map(t => { + if (t.id !== taskId) return t; + found = true; + return { + ...t, + ...(subject !== undefined ? { subject } : {}), + ...(activeForm !== undefined ? { activeForm } : {}), + ...(owner !== undefined ? { owner } : {}), + ...(status !== undefined ? { status } : {}), + }; + }); + // Unknown taskId — drop a synthetic row so the panel still surfaces the + // intent (the next TaskList will replace it cleanly). + if (!found && (subject || status)) { + next.push({ + id: taskId, + subject: subject || `Task #${taskId}`, + activeForm, + owner, + status: status || 'pending', + }); + } + return sortTasks(next); +} + +/** + * Parse a TaskList result. Each line looks like: + * #N [status] subject (owner) [blocked by #X, #Y] + * The owner and blocked-by suffixes are optional and may both be absent. + * Returns null if no usable lines are found (e.g. "No tasks found"), + * signalling the caller to clear the panel. + */ +export function parseCCTaskListResult( + text: string, + current: CCTask[], +): CCTask[] { + if (!text) return []; + if (/^\s*No tasks found\s*$/i.test(text)) return []; + const re = /^#(\d+)\s+\[([a-z_]+)\]\s+(.+?)(?:\s+\(([^)]+)\))?(?:\s+\[blocked by\s+([^\]]+)\])?\s*$/; + const out: CCTask[] = []; + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + const m = re.exec(line); + if (!m) continue; + const status = asStatus(m[2]) || 'pending'; + const blockedBy = m[5] + ? m[5].split(',').map(s => s.trim().replace(/^#/, '')).filter(Boolean) + : undefined; + const id = m[1]; + // Preserve any activeForm we already had locally — TaskList doesn't return it. + const known = current.find(t => t.id === id); + out.push({ + id, + subject: m[3].trim(), + activeForm: known?.activeForm, + status, + owner: m[4]?.trim() || undefined, + blockedBy, + }); + } + return sortTasks(out); +} + +/** + * Parse a TaskCreate result of the form: + * "Task #N created successfully: SUBJECT" + * On success, swap the placeholder row (keyed by toolUseId) for one with + * the real numeric id. If parsing fails, leave the placeholder so it's at + * least visible until the next TaskList. + */ +export function parseCCTaskCreateResult( + text: string, + current: CCTask[], + toolUseId: string, +): CCTask[] { + const m = /^Task\s+#(\d+)\s+created\s+successfully:\s+(.+)$/m.exec(text || ''); + if (!m) return current; + const realId = m[1]; + const subject = m[2].trim(); + const placeholder = placeholderId(toolUseId); + // If the real id already exists (re-broadcast / replay), just drop the placeholder. + if (current.some(t => t.id === realId)) { + return sortTasks(current.filter(t => t.id !== placeholder)); + } + let swapped = false; + const next = current.map(t => { + if (t.id === placeholder) { + swapped = true; + return { ...t, id: realId, subject }; + } + return t; + }); + if (!swapped) { + next.push({ id: realId, subject, status: 'pending' }); + } + return sortTasks(next); +} + +/** + * Parse a TaskGet result block. Multi-line; example: + * Task #1: Read the file + * Status: in_progress + * Description: ... + * [Blocked by: #2, #3] + * [Blocks: #4] + * + * Upserts a single task into the existing list. + */ +export function parseCCTaskGetResult( + text: string, + current: CCTask[], +): CCTask[] { + if (!text) return current; + if (/^\s*Task not found\s*$/i.test(text)) return current; + const idMatch = /^Task\s+#(\d+):\s+(.+)$/m.exec(text); + if (!idMatch) return current; + const id = idMatch[1]; + const subject = idMatch[2].trim(); + const statusMatch = /^Status:\s+([a-z_]+)\s*$/m.exec(text); + const status = asStatus(statusMatch?.[1]) || 'pending'; + const blockedByMatch = /^Blocked by:\s+(.+)$/m.exec(text); + const blockedBy = blockedByMatch + ? blockedByMatch[1].split(',').map(s => s.trim().replace(/^#/, '')).filter(Boolean) + : undefined; + let found = false; + const next = current.map(t => { + if (t.id !== id) return t; + found = true; + return { + ...t, + subject, + status, + ...(blockedBy !== undefined ? { blockedBy } : {}), + }; + }); + if (!found) { + next.push({ id, subject, status, blockedBy }); + } + return sortTasks(next); +} From 1a501eda990082790b6e99fb7e6c80f07aa50955 Mon Sep 17 00:00:00 2001 From: pufit Date: Mon, 25 May 2026 20:14:41 -0400 Subject: [PATCH 2/3] keep ScheduleWakeup enabled, render it instead of disabling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop ``disallowed_tools=["ScheduleWakeup"]`` and add a dedicated ``ScheduleWakeupBlock`` so the call surfaces in the UI with the parsed delay, scheduled wall-clock time, clamp marker, reason, and prompt. Claude Code persists wakeups in ``~/.claude/scheduled_tasks.json`` and fires past-due ones on the next CLI resume, so the tool isn't a hard no-op — Nerve just doesn't actively trigger them on a timer (no ``/loop`` skill). The card makes that contract clear with a small "stored by Claude Code; fires on next resume" note in the expanded view. --- nerve/agent/engine.py | 14 +- web/src/components/Chat/ToolCallBlock.tsx | 5 + .../Chat/tools/ScheduleWakeupBlock.tsx | 128 ++++++++++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 web/src/components/Chat/tools/ScheduleWakeupBlock.tsx diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 93e6c24..875432e 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -883,12 +883,14 @@ def _cli_stderr(line: str) -> None: # No allowed_tools — can_use_tool callback handles permissions. # External MCP server tools are discovered at connection time, # so we can't enumerate them upfront. - # ``disallowed_tools`` strips tools from the model's tool list - # entirely (cleaner than runtime denial). ``ScheduleWakeup`` is a - # Claude Code built-in for ``/loop`` dynamic mode — Nerve has no - # ``/loop`` skill, so the tool would be a dead end. Removing it - # avoids the model wasting turns calling it. - disallowed_tools=["ScheduleWakeup"], + # + # ``ScheduleWakeup`` is intentionally left enabled. Claude Code + # persists wakeups in ``~/.claude/scheduled_tasks.json``; on the + # next CLI resume, any past-due wakeup fires its stored prompt as + # an internal turn. Nerve doesn't actively trigger wakeups on a + # timer (no ``/loop`` skill), so the tool is largely informational + # — but the UI renders the call (see ``ScheduleWakeupBlock``) so + # the user can see what the model scheduled. env=self._build_env(), cwd=str(self.config.workspace), mcp_servers=self._build_mcp_servers(session_id), diff --git a/web/src/components/Chat/ToolCallBlock.tsx b/web/src/components/Chat/ToolCallBlock.tsx index b4f1542..daf7c10 100644 --- a/web/src/components/Chat/ToolCallBlock.tsx +++ b/web/src/components/Chat/ToolCallBlock.tsx @@ -9,6 +9,7 @@ import { FileToolBlock } from './tools/FileToolBlock'; import { MemoryToolBlock } from './tools/MemoryToolBlock'; import { TaskToolBlock } from './tools/TaskToolBlock'; import { CCTaskToolBlock } from './tools/CCTaskToolBlock'; +import { ScheduleWakeupBlock } from './tools/ScheduleWakeupBlock'; import { SourceToolBlock } from './tools/SourceToolBlock'; import { SubagentToolBlock } from './tools/SubagentToolBlock'; import { HoAToolBlock } from './tools/HoAToolBlock'; @@ -53,6 +54,10 @@ export function ToolCallBlock({ block }: { block: ToolCallBlockData }) { case 'TaskStop': case 'TaskOutput': return ; + // Claude Code 2.1+ self-paced ``/loop`` wakeup. Nerve has no timer + // for it, but we render the call so the user sees what was scheduled. + case 'ScheduleWakeup': + return ; case 'AskUserQuestion': return ; case 'ExitPlanMode': diff --git a/web/src/components/Chat/tools/ScheduleWakeupBlock.tsx b/web/src/components/Chat/tools/ScheduleWakeupBlock.tsx new file mode 100644 index 0000000..28a93f9 --- /dev/null +++ b/web/src/components/Chat/tools/ScheduleWakeupBlock.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { ChevronRight, ChevronDown, Clock, Loader2 } from 'lucide-react'; +import type { ToolCallBlockData } from '../../../types/chat'; +import { extractResultText } from '../../../utils/extractResultText'; + +/** "5m 30s" / "45s" / "1h 5m" — short relative-duration formatter. */ +function formatDelay(seconds: number): string { + const s = Math.max(0, Math.round(seconds)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + if (m < 60) return rem === 0 ? `${m}m` : `${m}m ${rem}s`; + const h = Math.floor(m / 60); + const mm = m % 60; + return mm === 0 ? `${h}h` : `${h}h ${mm}m`; +} + +/** Format a future epoch-ms timestamp as a short clock time. */ +function formatScheduledFor(epochMs: number): string { + try { + const d = new Date(epochMs); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } catch { + return ''; + } +} + +/** + * Render a `ScheduleWakeup` tool call. Claude Code uses this to self-pace + * `/loop` iterations — Nerve doesn't run a timer for it, but the call is + * still rendered so the user can see what the model planned. If a wakeup is + * ever past-due at next session resume, the CLI fires it on its own. + */ +export function ScheduleWakeupBlock({ block }: { block: ToolCallBlockData }) { + const [expanded, setExpanded] = useState(false); + const isRunning = block.status === 'running'; + + const input = block.input || {}; + const delaySeconds = typeof input.delaySeconds === 'number' ? input.delaySeconds : 0; + const reason = typeof input.reason === 'string' ? input.reason : ''; + const prompt = typeof input.prompt === 'string' ? input.prompt : ''; + + // Parse the result to surface the actual (clamped) delay and the wall-clock + // time the CLI scheduled the wakeup for. + let scheduledFor = 0; + let clampedDelaySeconds = 0; + let wasClamped = false; + if (block.result !== undefined && !block.isError) { + const text = extractResultText(block.result); + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object') { + if (typeof parsed.scheduledFor === 'number') scheduledFor = parsed.scheduledFor; + if (typeof parsed.clampedDelaySeconds === 'number') clampedDelaySeconds = parsed.clampedDelaySeconds; + if (typeof parsed.wasClamped === 'boolean') wasClamped = parsed.wasClamped; + } + } catch { /* result wasn't JSON; fall back to input.delaySeconds */ } + } + + const effectiveDelay = clampedDelaySeconds || delaySeconds; + const delayLabel = effectiveDelay ? formatDelay(effectiveDelay) : ''; + const timeLabel = scheduledFor ? formatScheduledFor(scheduledFor) : ''; + + return ( +
+ + + {expanded && ( +
+ {/* Note: Nerve doesn't actively trigger wakeups — the CLI persists + them and fires when the session next resumes. */} +
+ Stored by Claude Code; fires on next session resume if past-due. + Nerve has no /loop timer. +
+ + {prompt && ( +
+
Prompt
+
+                {prompt}
+              
+
+ )} + + {block.isError && block.result !== undefined && ( +
+
+                {extractResultText(block.result)}
+              
+
+ )} + + {isRunning && block.result === undefined && ( +
+ Scheduling... +
+ )} +
+ )} +
+ ); +} From 721a789814832dc86c08a1d158f632d8e430f725 Mon Sep 17 00:00:00 2001 From: pufit Date: Tue, 26 May 2026 02:11:44 -0400 Subject: [PATCH 3/3] correct ScheduleWakeup behaviour notes (it's a deferred prompt, not a timer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirically tested in a live Nerve session: * Scheduled at 02:05:24 for 02:07:00 (in 96s). * 02:07:00 — JSONL records queue-op + user-meta entries (CLI scheduler fired on time). * 02:07:00 — zero Nerve engine log activity (no turn started). * 02:07:26 — user typed "did it worked?" → Nerve called ``client.query()`` → SDK flushed the queued wakeup as a synthetic user message BEFORE the user's input. So the wakeup is not autonomous from Nerve's POV — it's a deferred prompt that piggybacks on the next user turn. If no turn ever comes before the idle client is reaped, the wakeup is lost (unless ``durable: true``, which the model rarely sets). Rewrote the comments in engine.py and the inline note in ScheduleWakeupBlock to reflect this. --- nerve/agent/engine.py | 23 ++++++---- .../Chat/tools/ScheduleWakeupBlock.tsx | 42 +++++++++++++++---- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 875432e..b78e8b9 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -884,13 +884,22 @@ def _cli_stderr(line: str) -> None: # External MCP server tools are discovered at connection time, # so we can't enumerate them upfront. # - # ``ScheduleWakeup`` is intentionally left enabled. Claude Code - # persists wakeups in ``~/.claude/scheduled_tasks.json``; on the - # next CLI resume, any past-due wakeup fires its stored prompt as - # an internal turn. Nerve doesn't actively trigger wakeups on a - # timer (no ``/loop`` skill), so the tool is largely informational - # — but the UI renders the call (see ``ScheduleWakeupBlock``) so - # the user can see what the model scheduled. + # ``ScheduleWakeup`` is left enabled. Behaviour in Nerve: + # • The CLI's internal scheduler "fires" the wakeup at the + # scheduled time and enqueues the stored prompt as a + # synthetic user message inside the SDK subprocess. + # • Nerve has no background reader between turns — the queued + # wakeup just sits in the SDK buffer. + # • On the next user message, Nerve calls ``client.query()`` + # and the buffered wakeup is flushed BEFORE the user's + # input, so the model sees the self-scheduled prompt first. + # • If the user never sends another message before the idle + # timeout reaps the client, the wakeup is lost (durable + # wakeups would survive in ~/.claude/scheduled_tasks.json, + # but the model rarely sets ``durable: true``). + # Net: it's a deferred-prompt, not an autonomous timer. The UI + # renders the call (see ``ScheduleWakeupBlock``) so the user + # knows what the model scheduled. env=self._build_env(), cwd=str(self.config.workspace), mcp_servers=self._build_mcp_servers(session_id), diff --git a/web/src/components/Chat/tools/ScheduleWakeupBlock.tsx b/web/src/components/Chat/tools/ScheduleWakeupBlock.tsx index 28a93f9..e36c43d 100644 --- a/web/src/components/Chat/tools/ScheduleWakeupBlock.tsx +++ b/web/src/components/Chat/tools/ScheduleWakeupBlock.tsx @@ -42,25 +42,41 @@ export function ScheduleWakeupBlock({ block }: { block: ToolCallBlockData }) { const prompt = typeof input.prompt === 'string' ? input.prompt : ''; // Parse the result to surface the actual (clamped) delay and the wall-clock - // time the CLI scheduled the wakeup for. - let scheduledFor = 0; + // time the CLI scheduled the wakeup for. The CLI returns plain text of the + // form "Next wakeup scheduled for HH:MM:SS (in Ns). ..."; some older / + // experimental builds also returned a JSON object with `scheduledFor`, + // `clampedDelaySeconds`, `wasClamped` — accept either shape. + let scheduledTimeLabel = ''; let clampedDelaySeconds = 0; let wasClamped = false; if (block.result !== undefined && !block.isError) { const text = extractResultText(block.result); + let parsedJson = false; try { const parsed = JSON.parse(text); if (parsed && typeof parsed === 'object') { - if (typeof parsed.scheduledFor === 'number') scheduledFor = parsed.scheduledFor; + if (typeof parsed.scheduledFor === 'number') { + scheduledTimeLabel = formatScheduledFor(parsed.scheduledFor); + } if (typeof parsed.clampedDelaySeconds === 'number') clampedDelaySeconds = parsed.clampedDelaySeconds; if (typeof parsed.wasClamped === 'boolean') wasClamped = parsed.wasClamped; + parsedJson = true; + } + } catch { /* fall through to text parser */ } + if (!parsedJson) { + // "Next wakeup scheduled for HH:MM[:SS] (in Ns)." + const m = /Next wakeup scheduled for (\d{1,2}:\d{2}(?::\d{2})?)\b[^(]*\(in\s+(\d+)s\)/i.exec(text); + if (m) { + scheduledTimeLabel = m[1].split(':').slice(0, 2).join(':'); + clampedDelaySeconds = parseInt(m[2], 10); + wasClamped = clampedDelaySeconds !== delaySeconds && delaySeconds > 0; } - } catch { /* result wasn't JSON; fall back to input.delaySeconds */ } + } } const effectiveDelay = clampedDelaySeconds || delaySeconds; const delayLabel = effectiveDelay ? formatDelay(effectiveDelay) : ''; - const timeLabel = scheduledFor ? formatScheduledFor(scheduledFor) : ''; + const timeLabel = scheduledTimeLabel; return (
@@ -92,11 +108,19 @@ export function ScheduleWakeupBlock({ block }: { block: ToolCallBlockData }) { {expanded && (
- {/* Note: Nerve doesn't actively trigger wakeups — the CLI persists - them and fires when the session next resumes. */} + {/* The CLI scheduler "fires" the wakeup on time and queues the + prompt inside the SDK subprocess. Nerve has no background + reader, so the queued wakeup waits there until the next user + message arrives — at which point Nerve flushes it BEFORE the + user's input. If no message ever arrives before the idle + client is reaped, the wakeup is lost (unless `durable: true`, + which the model rarely sets). Net: it's a deferred prompt + that piggybacks on the next user turn. */}
- Stored by Claude Code; fires on next session resume if past-due. - Nerve has no /loop timer. + Queued by the Claude Code CLI. Nerve has no /loop + timer, so the wakeup will only surface on the next user + message — and will arrive before that message in the + conversation.
{prompt && (