diff --git a/arduino-mcp-extension/README.md b/arduino-mcp-extension/README.md index a94b79bba37..dff34328968 100644 --- a/arduino-mcp-extension/README.md +++ b/arduino-mcp-extension/README.md @@ -377,6 +377,25 @@ offset, a `dropped` count if the 512 KB buffer overflowed in between, and `read` returns the familiar tail snapshot. `wait_for` resolves with the matching line, or `timed_out: true` / `disconnected: true` (never an error). +**Crash detection:** the output stream is scanned for crash/reset signatures +and matches come back as `events` on every `read`/`wait_for` response (and as +`event_count`/`last_event` in `get_config`): + +| Event type | Signature | +|------------|-----------| +| `reset` | ESP32 `rst:0x… (REASON)` boot line (reason in `detail`) | +| `panic` | `Guru Meditation Error` (cause in `detail`, backtrace attached) | +| `watchdog` | `Task watchdog got triggered`, AVR `wdt reset` | +| `brownout` | `Brownout detector was triggered` (usually power supply) | +| `abort` | `abort() was called` | + +A `reset`/`panic`/`brownout`/`abort` also **ends a pending `wait_for` early** +with the event attached — the output you were waiting for is not coming from a +board that just crashed. Watchdog warnings only record (they can be +transient). Note: on native-USB boards (ESP32-S2/S3/C3 with CDC), the ROM +bootloader's `rst:` line may not appear on the USB port; app-level panics and +watchdog messages still do. + ### arduino_library | Action | Parameters | Description | diff --git a/arduino-mcp-extension/src/common/mcp-tool-router.ts b/arduino-mcp-extension/src/common/mcp-tool-router.ts index 202b63785fa..f39e8dea03e 100644 --- a/arduino-mcp-extension/src/common/mcp-tool-router.ts +++ b/arduino-mcp-extension/src/common/mcp-tool-router.ts @@ -51,7 +51,7 @@ export const TOOL_CATEGORIES: ToolCategory[] = [ description: 'Serial monitor operations - connect, read output, send data to devices.', toolNames: ['arduino_serial'], useWhen: - 'Debugging via serial output, sending commands to device, waiting for specific output (wait_for), following logs losslessly with cursor-based reads', + 'Debugging via serial output, sending commands to device, waiting for specific output (wait_for), following logs losslessly with cursor-based reads, diagnosing crashes/resets via auto-detected events', }, { name: 'library', diff --git a/arduino-mcp-extension/src/common/mcp-tools.ts b/arduino-mcp-extension/src/common/mcp-tools.ts index 4b906287051..2094214615e 100644 --- a/arduino-mcp-extension/src/common/mcp-tools.ts +++ b/arduino-mcp-extension/src/common/mcp-tools.ts @@ -277,7 +277,7 @@ export const ARDUINO_TOOLS: ToolDefinition[] = [ { name: 'arduino_serial', description: - 'Serial monitor operations - connect to a board, read its output, send data, change the baud rate. Reads are cursor-based: every read returns a `cursor`; pass it back as `since` to page through output losslessly. wait_for blocks until a line matching a pattern arrives. The connection is shared with the IDE serial monitor.', + 'Serial monitor operations - connect to a board, read its output, send data, change the baud rate. Reads are cursor-based: every read returns a `cursor`; pass it back as `since` to page through output losslessly. wait_for blocks until a line matching a pattern arrives. Crash/reset signatures in the output (ESP32 panics, watchdog, brownout, reset reasons) are detected automatically and returned as `events` - a reset or crash also ends a pending wait_for early. The connection is shared with the IDE serial monitor.', inputSchema: { type: 'object', properties: { diff --git a/arduino-mcp-extension/src/node/mcp-serial-manager.ts b/arduino-mcp-extension/src/node/mcp-serial-manager.ts index 66048a7c339..568e3985737 100644 --- a/arduino-mcp-extension/src/node/mcp-serial-manager.ts +++ b/arduino-mcp-extension/src/node/mcp-serial-manager.ts @@ -26,7 +26,52 @@ export const SUPPORTED_BAUD_RATES = [ const MAX_BUFFER_CHARS = 512 * 1024; // cap the capture buffer at 512 KB -/** Result shape for waitFor - see that method for the three resolutions. */ +/** + * A crash/reset signature detected in the board's output. Surfaced through + * read/wait_for/status so an agent can tell "the board rebooted five times" + * apart from "the board is quiet" - previously these looked identical. + */ +export interface SerialEvent { + type: 'reset' | 'panic' | 'watchdog' | 'brownout' | 'abort'; + /** The line that triggered the detection. */ + line: string; + /** Global cursor just past that line. */ + cursor: number; + timestamp: number; + /** Reset reason, panic cause, or the first backtrace line. */ + detail?: string; +} + +const MAX_EVENTS = 50; + +/** + * Line-anchored signatures for the funnel scanner. Order matters: the first + * match wins. ESP32 (esp-idf) signatures plus the classic AVR wdt marker. + */ +const EVENT_SIGNATURES: Array<{ + type: SerialEvent['type']; + pattern: RegExp; + detail?: (match: RegExpMatchArray) => string; +}> = [ + { + type: 'panic', + pattern: /Guru Meditation Error:?\s*(.*)/, + detail: (m) => m[1]?.trim() || 'panic', + }, + { type: 'brownout', pattern: /Brownout detector was triggered/ }, + { type: 'abort', pattern: /abort\(\) was called/ }, + { + type: 'watchdog', + pattern: /Task watchdog got triggered|\bwdt reset/i, + }, + { + type: 'reset', + pattern: /^rst:0x[0-9a-f]+\s*\(([^)]+)\)/i, + detail: (m) => m[1], + }, +]; + +/** Result shape for waitFor - see that method for the resolutions. */ export interface SerialWaitResult { matched: boolean; line?: string; @@ -35,6 +80,9 @@ export interface SerialWaitResult { timed_out?: boolean; disconnected?: boolean; hint?: string; + /** Set when a crash/reset ended the wait early. */ + event?: SerialEvent; + message?: string; } interface SerialWaiter { @@ -63,6 +111,10 @@ interface ActiveConnection { lineRemainder: string; /** Pending wait_for calls, resolved by the line scanner. */ waiters: SerialWaiter[]; + /** Detected crash/reset events, oldest first, capped at MAX_EVENTS. */ + events: SerialEvent[]; + /** Last panic/abort event still waiting for its Backtrace line. */ + crashPendingDetail: SerialEvent | null; } function escapeRegExp(text: string): string { @@ -203,6 +255,8 @@ export class MCPSerialManager { bufferStartOffset: 0, lineRemainder: '', waiters: [], + events: [], + crashPendingDetail: null, }; ws.on('open', () => { @@ -283,6 +337,8 @@ export class MCPSerialManager { line: string, lineEndCursor: number ): void { + this.classifyLine(connection, line, lineEndCursor); + if (!connection.waiters.length) { return; } @@ -303,6 +359,70 @@ export class MCPSerialManager { } } + /** Matches crash/reset signatures and records SerialEvents. */ + private classifyLine( + connection: ActiveConnection, + line: string, + lineEndCursor: number + ): void { + // A panic/abort is followed by its backtrace a few lines later - attach it + // as detail instead of recording a separate event. + if (connection.crashPendingDetail && /^Backtrace:/.test(line)) { + connection.crashPendingDetail.detail = line; + connection.crashPendingDetail = null; + return; + } + + for (const signature of EVENT_SIGNATURES) { + const match = line.match(signature.pattern); + if (!match) { + continue; + } + const event: SerialEvent = { + type: signature.type, + line, + cursor: lineEndCursor, + timestamp: Date.now(), + detail: signature.detail?.(match), + }; + connection.events.push(event); + if (connection.events.length > MAX_EVENTS) { + connection.events.shift(); + } + if (event.type === 'panic' || event.type === 'abort') { + connection.crashPendingDetail = event; + } + // Terminal events end pending waits early: the awaited output is not + // coming from a board that just crashed or rebooted. A watchdog warning + // can be transient (it does not always abort), so it only records. + if (event.type !== 'watchdog') { + this.resolveWaitersWithEvent(connection, event); + } + return; // first signature wins + } + } + + private resolveWaitersWithEvent( + connection: ActiveConnection, + event: SerialEvent + ): void { + const waiters = connection.waiters; + connection.waiters = []; + for (const waiter of waiters) { + clearTimeout(waiter.timer); + waiter.resolve({ + matched: false, + event, + cursor: event.cursor, + message: `Board ${ + event.type === 'reset' ? 'reset' : 'crashed' + } while waiting (${event.type}${ + event.detail ? `: ${event.detail}` : '' + })`, + }); + } + } + /** Resolves every pending waiter as disconnected (close, error, disconnect). */ private flushWaiters(connection: ActiveConnection): void { const waiters = connection.waiters; @@ -356,6 +476,7 @@ export class MCPSerialManager { cursor: number; dropped: number; has_more: boolean; + events: SerialEvent[]; } { const connection = this.requireConnection(); const end = connection.bufferStartOffset + connection.buffer.length; @@ -373,6 +494,7 @@ export class MCPSerialManager { cursor: end, dropped: 0, has_more: false, + events: [...connection.events], }; } @@ -401,6 +523,8 @@ export class MCPSerialManager { cursor: clamped + pos, dropped, has_more: region.indexOf('\n', pos) !== -1, + // Only events the caller has not seen yet. + events: connection.events.filter((e) => e.cursor > since), }; } @@ -514,6 +638,8 @@ export class MCPSerialManager { c.bufferStartOffset += c.buffer.length; c.buffer = ''; c.lineRemainder = ''; + c.events = []; + c.crashPendingDetail = null; } } @@ -524,6 +650,8 @@ export class MCPSerialManager { board: string | null; cursor: number | null; buffered_chars: number | null; + event_count: number; + last_event: SerialEvent | null; } { const c = this.connection; return { @@ -533,6 +661,8 @@ export class MCPSerialManager { board: c?.board.name ?? null, cursor: c ? c.bufferStartOffset + c.buffer.length : null, buffered_chars: c ? c.buffer.length : null, + event_count: c?.events.length ?? 0, + last_event: c?.events.length ? c.events[c.events.length - 1] : null, }; }