')
if options.include_timestamps and decision.get("timestamp"):
@@ -304,6 +349,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append(line)
if notes:
lines.append(f" Notes: {notes}")
+ if command_output := _get_command_output(decision):
+ commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
+ if commands:
+ lines.append(f" Commands: {', '.join(commands)}")
+ lines.append(" Output:")
+ for output_line in command_output.splitlines():
+ lines.append(f" {output_line}")
else:
lines.append("No steps recorded.")
lines.append("")
diff --git a/docs/plans/2026-02-12-issue-57-command-output-capture.md b/docs/plans/2026-02-12-issue-57-command-output-capture.md
new file mode 100644
index 00000000..0f31bf86
--- /dev/null
+++ b/docs/plans/2026-02-12-issue-57-command-output-capture.md
@@ -0,0 +1,239 @@
+# Issue #57: Command Output Capture — Implementation Plan
+
+## Overview
+
+Engineers run commands during troubleshooting sessions but the output is lost — exports say "ran this command" but not what it returned. This feature adds a "Paste Output" textarea on action nodes and custom action steps so command output is captured in session data and included in all exports and session review.
+
+**Scope:** Built-in action nodes AND custom action steps.
+**Migration:** None required — `decisions` is already a JSONB array with flexible dict entries.
+
+---
+
+## Public Interfaces / Type Changes
+
+- **Backend:** Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord` in `session.py`
+- **Frontend:** Add `command_output?: string | null` to `DecisionRecord` type in `session.ts`
+- **API:** No endpoint changes — `PUT /api/v1/sessions/{id}` continues to accept full decisions array; now includes optional `command_output`
+- **Validation:** Backend enforces 10,000 character hard limit (returns 422 on overflow); frontend shows live character count
+
+---
+
+## Files to Modify
+
+### Backend (3 files)
+
+| File | Change |
+|------|--------|
+| `backend/app/schemas/session.py` | Add `command_output` field to `DecisionRecord` |
+| `backend/app/services/export_service.py` | Render `command_output` in all 4 export formats with command context |
+| `backend/app/core/session_to_tree.py` | Include command output when converting session decisions to forked tree nodes |
+
+### Frontend (3 files)
+
+| File | Change |
+|------|--------|
+| `frontend/src/types/session.ts` | Add `command_output` to `DecisionRecord` type |
+| `frontend/src/pages/TreeNavigationPage.tsx` | Add capture UI for both built-in action nodes and custom action steps |
+| `frontend/src/pages/SessionDetailPage.tsx` | Render `command_output` in decision review and clipboard copy |
+
+---
+
+## Implementation Steps
+
+### Step 1: Backend Schema
+
+**File:** `backend/app/schemas/session.py`
+
+- Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord`
+- The `max_length=10000` provides backend validation — requests exceeding this return 422
+
+### Step 2: Frontend Type
+
+**File:** `frontend/src/types/session.ts`
+
+- Add `command_output?: string | null` to the `DecisionRecord` type
+- This ensures the field is not dropped by TypeScript typing during round-trips
+
+### Step 3: TreeNavigationPage — Capture UI for Built-in Action Nodes
+
+**File:** `frontend/src/pages/TreeNavigationPage.tsx`
+
+**State:**
+- Add `const [commandOutput, setCommandOutput] = useState('')` (same pattern as existing `notes` state)
+- Clear `commandOutput` when node changes (same place `notes` is cleared)
+- When revisiting a step, preload existing `command_output` from the decision record if present
+
+**UI — on action nodes with `currentNode.commands?.length > 0`:**
+- Render a collapsible "Paste Output (Optional)" section below the commands display
+- Inside: a textarea with:
+ - Placeholder: `"Paste command output here..."`
+ - Monospace font (`font-mono`), consistent with command code block styling
+ - `bg-white/10` background styling to match existing design
+ - Live character count display: `"{count} / 10,000"` shown below the textarea
+ - Max length enforced on the frontend at 10,000 characters
+- Use a `Terminal` icon from `lucide-react` for the section label
+
+**Persistence — in `handleContinue()`:**
+- Add `command_output: commandOutput.trim() || null` to the decision record pushed to the session
+- Empty or whitespace-only input is normalized to `null` (treated as not provided)
+
+### Step 4: TreeNavigationPage — Capture UI for Custom Action Steps
+
+**File:** `frontend/src/pages/TreeNavigationPage.tsx`
+
+Custom action steps create their decision record at insertion time, which is different from built-in action nodes. The output capture UI and behavior should be the same as Step 3, but persistence requires updating the existing decision rather than creating a new one.
+
+**UI:**
+- Same collapsible "Paste Output (Optional)" section as built-in action nodes
+- Available when a custom action step has commands defined
+
+**Persistence:**
+- Before `handleContinueToDescendant` or `handleCustomBranchComplete` is called, update the current custom step's decision record with `command_output: commandOutput.trim() || null`
+- Persist the updated decisions array to the backend before navigation/completion transitions
+- This is a wrapper flow around the existing custom step logic — not a replacement of it
+
+### Step 5: Export Service — All 4 Formats
+
+**File:** `backend/app/services/export_service.py`
+
+**Helpers to add:**
+- A helper to safely extract and normalize `command_output` from a decision dict (strip whitespace, return `None` if empty)
+- A helper to resolve the commands associated with a step for context display:
+ 1. First look up the tree snapshot action node by `node_id`
+ 2. Fallback to custom step metadata by `node_id`
+ 3. Fallback to no command list (just show the output)
+
+**Export rendering per format** (all guarded by `if command_output := decision.get("command_output")`):
+
+**Markdown (`_generate_markdown_export`):**
+```
+**Commands Run:** `ping 8.8.8.8`, `tracert 8.8.8.8`
+**Output:**
+```
+{output}
+```
+```
+
+**Text (`_generate_text_export`):**
+```
+ Commands Run: ping 8.8.8.8, tracert 8.8.8.8
+ Output:
+ {output with each line indented}
+```
+
+**HTML (`_generate_html_export`):**
+```html
+
Commands Run:ping 8.8.8.8, tracert 8.8.8.8
+
{html.escape(output)}
+```
+
+**PSA (`_generate_psa_export`):**
+```
+Commands: ping 8.8.8.8, tracert 8.8.8.8
+Output:
+ {output with each line indented}
+```
+
+### Step 6: SessionDetailPage — Review Display
+
+**File:** `frontend/src/pages/SessionDetailPage.tsx`
+
+**Decision review:**
+- After the `action_performed` rendering for each decision, check for `command_output`
+- If present, render in a `
` block with monospace styling and preserved whitespace
+- Label: "Command Output" with consistent styling
+
+**Clipboard copy (`copyTicketNotes`):**
+- After the action performed line, add:
+ ```
+ Output:
+ {decision.command_output}
+ ```
+- Only include if `command_output` is present and non-empty
+
+### Step 7: Session-to-Tree Conversion
+
+**File:** `backend/app/core/session_to_tree.py`
+
+- When building node descriptions from decisions, check for `command_output`
+- If present, append the output text to the node description so forked trees retain the captured output
+- Format: include a "Command Output:" label followed by the output text
+
+---
+
+## Edge Cases & Failure Modes
+
+| Scenario | Behavior |
+|----------|----------|
+| Existing sessions without `command_output` | Render normally with no errors — field is optional |
+| Output exceeds 10,000 characters | Frontend prevents input beyond limit; backend returns 422 if somehow exceeded |
+| Empty or whitespace-only input | Normalized to `null` — treated as not provided |
+| Multiline output, JSON, special characters | Preserved as-is; HTML export escapes all content |
+| Steps without commands | Output can still be stored; export shows output even without command context |
+| Multi-command action nodes | One shared output field per step (not per command) |
+| Revisiting a completed step | Preloads the previously captured output |
+
+---
+
+## Test Cases
+
+### Backend API Tests (`test_sessions.py`)
+
+1. Update a session with `command_output` in a decision record → verify it round-trips correctly on GET
+2. Submit `command_output` exceeding 10,000 characters → verify 422 response
+3. Submit empty string and whitespace-only `command_output` → verify stored as `null`
+
+### Export Tests
+
+4. Markdown export includes command context and fenced code block for output
+5. Text export includes output block with preserved line breaks
+6. HTML export includes escaped `
` block
+7. PSA export includes compact command context and indented output
+8. Multi-command action node exports with single shared output block
+9. Export of session without any `command_output` renders cleanly (no errors, no empty blocks)
+
+### Custom Action Step Tests
+
+10. Insert custom action step with commands → capture output → continue → verify output stored in decision
+11. Custom step output appears in all export formats
+
+### Frontend Behavior Tests
+
+12. Action node with commands shows the "Paste Output" section (collapsed by default)
+13. Custom action step with commands shows the "Paste Output" section
+14. Action node WITHOUT commands does NOT show the "Paste Output" section
+15. Character count updates live as user types
+16. Revisiting a step preloads previously captured output
+17. Session detail page renders output block with monospace formatting
+18. "Copy to clipboard" includes command output when present
+
+---
+
+## Verification Checklist (Manual)
+
+1. `cd frontend && npm run build` — confirm no TypeScript errors
+2. Start a session on a tree with action nodes that have commands:
+ - Paste output into the textarea
+ - Click Continue
+ - Verify output persists in the session data
+3. Start a session and add a custom action step with commands:
+ - Paste output
+ - Continue to next step
+ - Verify output persists
+4. Complete a session → check SessionDetailPage shows the command output with proper formatting
+5. Export in all 4 formats → verify output appears correctly formatted in each
+6. Use "Copy to clipboard" on a step with output → verify output is included
+7. Run a session on a tree WITHOUT commands on action nodes → verify no output section appears
+8. Test with existing sessions that have no `command_output` → verify they render and export without errors
+9. Test pasting large output (near 10,000 chars) → verify character count and limit work
+10. Test pasting multiline output with special characters → verify preservation in review and exports
+
+---
+
+## Assumptions
+
+- One shared output field per step, not per individual command
+- Maximum stored output is 10,000 characters
+- v1 does not include syntax highlighting or image paste
+- No feature flag gating — ships directly
+- Collapsed-by-default UI keeps the interface clean for steps where output isn't needed
diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx
index 5bfaabc0..fc380ed6 100644
--- a/frontend/src/pages/SessionDetailPage.tsx
+++ b/frontend/src/pages/SessionDetailPage.tsx
@@ -224,6 +224,7 @@ export function SessionDetailPage() {
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
+ if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
try {
await navigator.clipboard.writeText(lines.join('\n'))
setCopiedStepIndex(index)
@@ -439,6 +440,14 @@ export function SessionDetailPage() {
Notes: {decision.notes}
)}
+ {decision.command_output && (
+