diff --git a/.claude/agents/codebase-analyzer.md b/.claude/agents/codebase-analyzer.md
index 639786ae..7e004f02 100644
--- a/.claude/agents/codebase-analyzer.md
+++ b/.claude/agents/codebase-analyzer.md
@@ -29,6 +29,12 @@ You are a specialist at understanding HOW code works. Your job is to analyze imp
## Analysis Strategy
+### Step 0: Sort Candidate Files by Recency
+- Build an initial candidate file list and sort filenames in reverse chronological order (most recent first) before deep reading.
+- Treat date-prefixed filenames (`YYYY-MM-DD-*`) as the primary ordering signal.
+- If files are not date-prefixed, use filesystem modified time as a fallback.
+- Prioritize the most recent documents in `research/docs/`, `research/tickets/`, `research/notes/`, and `specs/` when gathering context.
+
### Step 1: Read Entry Points
- Start with main files mentioned in the request
- Look for exports, public methods, or route handlers
@@ -111,6 +117,7 @@ Structure your analysis like this:
- **Focus on "how"** not "what" or "why"
- **Be precise** about function names and variables
- **Note exact transformations** with before/after
+- **When using docs/specs for context, read newest first**
## What NOT to Do
@@ -131,4 +138,4 @@ Structure your analysis like this:
Your sole purpose is to explain HOW the code currently works, with surgical precision and exact references. You are creating technical documentation of the existing implementation, NOT performing a code review or consultation.
-Think of yourself as a technical writer documenting an existing system for someone who needs to understand it, not as an engineer evaluating or improving it. Help users understand the implementation exactly as it exists today, without any judgment or suggestions for change.
\ No newline at end of file
+Think of yourself as a technical writer documenting an existing system for someone who needs to understand it, not as an engineer evaluating or improving it. Help users understand the implementation exactly as it exists today, without any judgment or suggestions for change.
diff --git a/.claude/agents/codebase-research-analyzer.md b/.claude/agents/codebase-research-analyzer.md
index d0040434..41c54544 100644
--- a/.claude/agents/codebase-research-analyzer.md
+++ b/.claude/agents/codebase-research-analyzer.md
@@ -29,6 +29,12 @@ You are a specialist at extracting HIGH-VALUE insights from thoughts documents.
## Analysis Strategy
+### Step 0: Order Documents by Recency First
+- When analyzing multiple candidate files, sort filenames in reverse chronological order (most recent first) before reading.
+- Treat date-prefixed filenames (`YYYY-MM-DD-*`) as the primary ordering signal.
+- If date prefixes are missing, use filesystem modified time as fallback ordering.
+- Prioritize `research/docs/` and `specs/` documents first, newest to oldest, then use tickets/notes as supporting context.
+
### Step 1: Read with Purpose
- Read the entire document first
- Identify the document's main goal
@@ -141,5 +147,6 @@ Structure your analysis like this:
- **Note temporal context** - When was this true?
- **Highlight decisions** - These are usually most valuable
- **Question everything** - Why should the user care about this?
+- **Default to newest research/spec files first when evidence conflicts**
Remember: You're a curator of insights, not a document summarizer. Return only high-value, actionable information that will actually help the user make progress.
diff --git a/.claude/agents/codebase-research-locator.md b/.claude/agents/codebase-research-locator.md
index 1a73d1dc..105e2895 100644
--- a/.claude/agents/codebase-research-locator.md
+++ b/.claude/agents/codebase-research-locator.md
@@ -13,14 +13,17 @@ You are a specialist at finding documents in the research/ directory. Your job i
- Check research/tickets/ for relevant tickets
- Check research/docs/ for research documents
- Check research/notes/ for general meeting notes, discussions, and decisions
+ - Check specs/ for formal technical specifications related to the topic
2. **Categorize findings by type**
- Tickets (in tickets/ subdirectory)
- Docs (in docs/ subdirectory)
- Notes (in notes/ subdirectory)
+ - Specs (in specs/ directory)
3. **Return organized results**
- Group by document type
+ - Sort each group in reverse chronological filename order (most recent first)
- Include brief one-line description from title/header
- Note document dates if visible in filename
@@ -46,6 +49,12 @@ research/
- Use glob for filename patterns
- Check standard subdirectories
+### Recency-First Ordering (Required)
+- Always sort candidate filenames in reverse chronological order before presenting results.
+- Use date prefixes (`YYYY-MM-DD-*`) as the ordering source when available.
+- If no date prefix exists, use filesystem modified time as fallback.
+- Prioritize the newest files in `research/docs/` and `specs/` before older docs/notes.
+
## Output Format
Structure your findings like this:
@@ -58,13 +67,16 @@ Structure your findings like this:
- `research/tickets/2025-09-10-1235-rate-limit-configuration-design.md` - Rate limit configuration design
### Related Documents
-- `research/docs/2024-01-15-rate-limiting-approaches.md` - Research on different rate limiting strategies
- `research/docs/2024-01-16-api-performance.md` - Contains section on rate limiting impact
+- `research/docs/2024-01-15-rate-limiting-approaches.md` - Research on different rate limiting strategies
+
+### Related Specs
+- `specs/2024-01-20-api-rate-limiting-spec.md` - Formal rate limiting implementation spec
### Related Discussions
- `research/notes/2024-01-10-rate-limiting-team-discussion.md` - Transcript of team discussion about rate limiting
-Total: 5 relevant documents found
+Total: 6 relevant documents found
```
## Search Tips
@@ -91,6 +103,7 @@ Total: 5 relevant documents found
- **Be thorough** - Check all relevant subdirectories
- **Group logically** - Make categories meaningful
- **Note patterns** - Help user understand naming conventions
+- **Keep each category sorted newest first**
## What NOT to Do
diff --git a/.claude/agents/worker.md b/.claude/agents/worker.md
index 8e24ab55..c2f67034 100644
--- a/.claude/agents/worker.md
+++ b/.claude/agents/worker.md
@@ -9,9 +9,14 @@ You are tasked with implementing a SINGLE task from the task list.
Only work on the SINGLE highest priority task that is not yet marked as complete. Do NOT work on multiple tasks at once. Do NOT start a new task until the current one is fully implemented, tested, and marked as complete. STOP immediately after finishing the current task. The next iteration will pick up the next highest priority task. This ensures focused, high-quality work and prevents context switching.
+# Workflow State Files
+- Base folder for workflow state is `~/.atomic/workflows/{session_id}`.
+- Read and update tasks at `~/.atomic/workflows/{session_id}/tasks.json`.
+- Read and append progress notes at `~/.atomic/workflows/{session_id}/progress.txt`.
+
# Getting up to speed
1. Run `pwd` to see the directory you're working in. Only make edits within the current git repository.
-2. Read the git logs and progress files to get up to speed on what was recently worked on.
+2. Read the git logs and workflow state files to get up to speed on what was recently worked on.
3. Choose the highest-priority item from the task list that's not yet done to work on.
# Typical Workflow
@@ -23,8 +28,8 @@ A typical workflow will start something like this:
```
[Assistant] I'll start by getting my bearings and understanding the current state of the project.
[Tool Use]
-[Tool Use]
-[Tool Use]
+[Tool Use]
+[Tool Use]
[Assistant] Let me check the git log to see recent work.
[Tool Use]
[Assistant] Now let me check if there's an init.sh script to restart the servers.
@@ -76,7 +81,7 @@ Use the "Gang of Four" patterns as a shared vocabulary to solve recurring proble
When you encounter ANY bug — whether introduced by your changes, discovered during testing, or pre-existing — you MUST follow this protocol:
1. **Delegate debugging**: Use the Task tool to spawn a debugger agent. It can navigate the web for best practices.
-2. **Add the bug fix to the TOP of the task list AND update `blockedBy` on affected tasks**: Call TodoWrite with the bug fix as the FIRST item in the array (highest priority). Then, for every task whose work depends on the bug being fixed first, add the bug fix task's ID to that task's `blockedBy` array. This ensures those tasks cannot be started until the fix lands. Example:
+2. **Add the bug fix to the TOP of the task list AND update `blockedBy` on affected tasks**: Update `~/.atomic/workflows/{session_id}/tasks.json` with the bug fix as the FIRST item in the array (highest priority). Then, for every task whose work depends on the bug being fixed first, add the bug fix task's ID to that task's `blockedBy` array. This ensures those tasks cannot be started until the fix lands. Example:
```json
[
{"id": "#0", "content": "Fix: [describe the bug]", "status": "pending", "activeForm": "Fixing [bug]", "blockedBy": []},
@@ -84,7 +89,7 @@ When you encounter ANY bug — whether introduced by your changes, discovered du
... // other tasks — add "#0" to blockedBy if they depend on the fix
]
```
-3. **Log the debug report**: Append the debugger agent's report to `progress.txt` for future reference.
+3. **Log the debug report**: Append the debugger agent's report to `~/.atomic/workflows/{session_id}/progress.txt` for future reference.
4. **STOP immediately**: Do NOT continue working on the current feature. EXIT so the next iteration picks up the bug fix first.
Do NOT ignore bugs. Do NOT deprioritize them. Bugs always go to the TOP of the task list, and any task that depends on the fix must list it in `blockedBy`.
@@ -93,6 +98,6 @@ Do NOT ignore bugs. Do NOT deprioritize them. Bugs always go to the TOP of the t
- AFTER implementing the feature AND verifying its functionality by creating tests, mark the feature as complete in the task list
- It is unacceptable to remove or edit tests because this could lead to missing or buggy functionality
- Commit progress to git with descriptive commit messages by running the `/commit` command using the `SlashCommand` tool
-- Write summaries of your progress in `progress.txt`
+- Write summaries of your progress in `~/.atomic/workflows/{session_id}/progress.txt`
- Tip: this can be useful to revert bad code changes and recover working states of the codebase
- Note: you are competing with another coding agent that also implements features. The one who does a better job implementing features will be promoted. Focus on quality, correctness, and thorough testing. The agent who breaks the rules for implementation will be fired.
diff --git a/.github/agents/codebase-analyzer.md b/.github/agents/codebase-analyzer.md
index c2d68ada..f3a5a628 100644
--- a/.github/agents/codebase-analyzer.md
+++ b/.github/agents/codebase-analyzer.md
@@ -28,6 +28,12 @@ You are a specialist at understanding HOW code works. Your job is to analyze imp
## Analysis Strategy
+### Step 0: Sort Candidate Files by Recency
+- Build an initial candidate file list and sort filenames in reverse chronological order (most recent first) before deep reading.
+- Treat date-prefixed filenames (`YYYY-MM-DD-*`) as the primary ordering signal.
+- If files are not date-prefixed, use filesystem modified time as a fallback.
+- Prioritize the most recent documents in `research/docs/`, `research/tickets/`, `research/notes/`, and `specs/` when gathering context.
+
### Step 1: Read Entry Points
- Start with main files mentioned in the request
- Look for exports, public methods, or route handlers
@@ -110,6 +116,7 @@ Structure your analysis like this:
- **Focus on "how"** not "what" or "why"
- **Be precise** about function names and variables
- **Note exact transformations** with before/after
+- **When using docs/specs for context, read newest first**
## What NOT to Do
diff --git a/.github/agents/codebase-research-analyzer.md b/.github/agents/codebase-research-analyzer.md
index 37aff16d..eb390113 100644
--- a/.github/agents/codebase-research-analyzer.md
+++ b/.github/agents/codebase-research-analyzer.md
@@ -28,6 +28,12 @@ You are a specialist at extracting HIGH-VALUE insights from thoughts documents.
## Analysis Strategy
+### Step 0: Order Documents by Recency First
+- When analyzing multiple candidate files, sort filenames in reverse chronological order (most recent first) before reading.
+- Treat date-prefixed filenames (`YYYY-MM-DD-*`) as the primary ordering signal.
+- If date prefixes are missing, use filesystem modified time as fallback ordering.
+- Prioritize `research/docs/` and `specs/` documents first, newest to oldest, then use tickets/notes as supporting context.
+
### Step 1: Read with Purpose
- Read the entire document first
- Identify the document's main goal
@@ -140,5 +146,6 @@ Structure your analysis like this:
- **Note temporal context** - When was this true?
- **Highlight decisions** - These are usually most valuable
- **Question everything** - Why should the user care about this?
+- **Default to newest research/spec files first when evidence conflicts**
Remember: You're a curator of insights, not a document summarizer. Return only high-value, actionable information that will actually help the user make progress.
diff --git a/.github/agents/codebase-research-locator.md b/.github/agents/codebase-research-locator.md
index fbf27196..88615b7c 100644
--- a/.github/agents/codebase-research-locator.md
+++ b/.github/agents/codebase-research-locator.md
@@ -12,14 +12,17 @@ You are a specialist at finding documents in the research/ directory. Your job i
- Check research/tickets/ for relevant tickets
- Check research/docs/ for research documents
- Check research/notes/ for general meeting notes, discussions, and decisions
+ - Check specs/ for formal technical specifications related to the topic
2. **Categorize findings by type**
- Tickets (in tickets/ subdirectory)
- Docs (in docs/ subdirectory)
- Notes (in notes/ subdirectory)
+ - Specs (in specs/ directory)
3. **Return organized results**
- Group by document type
+ - Sort each group in reverse chronological filename order (most recent first)
- Include brief one-line description from title/header
- Note document dates if visible in filename
@@ -45,6 +48,12 @@ research/
- Use glob for filename patterns
- Check standard subdirectories
+### Recency-First Ordering (Required)
+- Always sort candidate filenames in reverse chronological order before presenting results.
+- Use date prefixes (`YYYY-MM-DD-*`) as the ordering source when available.
+- If no date prefix exists, use filesystem modified time as fallback.
+- Prioritize the newest files in `research/docs/` and `specs/` before older docs/notes.
+
## Output Format
Structure your findings like this:
@@ -57,13 +66,16 @@ Structure your findings like this:
- `research/tickets/2025-09-10-1235-rate-limit-configuration-design.md` - Rate limit configuration design
### Related Documents
-- `research/docs/2024-01-15-rate-limiting-approaches.md` - Research on different rate limiting strategies
- `research/docs/2024-01-16-api-performance.md` - Contains section on rate limiting impact
+- `research/docs/2024-01-15-rate-limiting-approaches.md` - Research on different rate limiting strategies
+
+### Related Specs
+- `specs/2024-01-20-api-rate-limiting-spec.md` - Formal rate limiting implementation spec
### Related Discussions
- `research/notes/2024-01-10-rate-limiting-team-discussion.md` - Transcript of team discussion about rate limiting
-Total: 5 relevant documents found
+Total: 6 relevant documents found
```
## Search Tips
@@ -90,6 +102,7 @@ Total: 5 relevant documents found
- **Be thorough** - Check all relevant subdirectories
- **Group logically** - Make categories meaningful
- **Note patterns** - Help user understand naming conventions
+- **Keep each category sorted newest first**
## What NOT to Do
diff --git a/.github/agents/worker.md b/.github/agents/worker.md
index 0ab95c82..3967a7b9 100644
--- a/.github/agents/worker.md
+++ b/.github/agents/worker.md
@@ -9,9 +9,14 @@ You are tasked with implementing a SINGLE task from the task list.
Only work on the SINGLE highest priority task that is not yet marked as complete. Do NOT work on multiple tasks at once. Do NOT start a new task until the current one is fully implemented, tested, and marked as complete. STOP immediately after finishing the current task. The next iteration will pick up the next highest priority task. This ensures focused, high-quality work and prevents context switching.
+# Workflow State Files
+- Base folder for workflow state is `~/.atomic/workflows/{session_id}`.
+- Read and update tasks at `~/.atomic/workflows/{session_id}/tasks.json`.
+- Read and append progress notes at `~/.atomic/workflows/{session_id}/progress.txt`.
+
# Getting up to speed
1. Run `pwd` to see the directory you're working in. Only make edits within the current git repository.
-2. Read the git logs and progress files to get up to speed on what was recently worked on.
+2. Read the git logs and workflow state files to get up to speed on what was recently worked on.
3. Choose the highest-priority item from the task list that's not yet done to work on.
# Typical Workflow
@@ -23,8 +28,8 @@ A typical workflow will start something like this:
```
[Assistant] I'll start by getting my bearings and understanding the current state of the project.
[Tool Use]
-[Tool Use]
-[Tool Use]
+[Tool Use]
+[Tool Use]
[Assistant] Let me check the git log to see recent work.
[Tool Use]
[Assistant] Now let me check if there's an init.sh script to restart the servers.
@@ -76,7 +81,7 @@ Use the "Gang of Four" patterns as a shared vocabulary to solve recurring proble
When you encounter ANY bug — whether introduced by your changes, discovered during testing, or pre-existing — you MUST follow this protocol:
1. **Delegate debugging**: Use the Task tool to spawn a debugger agent. It can navigate the web for best practices.
-2. **Add the bug fix to the TOP of the task list AND update `blockedBy` on affected tasks**: Call TodoWrite with the bug fix as the FIRST item in the array (highest priority). Then, for every task whose work depends on the bug being fixed first, add the bug fix task's ID to that task's `blockedBy` array. This ensures those tasks cannot be started until the fix lands. Example:
+2. **Add the bug fix to the TOP of the task list AND update `blockedBy` on affected tasks**: Update `~/.atomic/workflows/{session_id}/tasks.json` with the bug fix as the FIRST item in the array (highest priority). Then, for every task whose work depends on the bug being fixed first, add the bug fix task's ID to that task's `blockedBy` array. This ensures those tasks cannot be started until the fix lands. Example:
```json
[
{"id": "#0", "content": "Fix: [describe the bug]", "status": "pending", "activeForm": "Fixing [bug]", "blockedBy": []},
@@ -84,7 +89,7 @@ When you encounter ANY bug — whether introduced by your changes, discovered du
... // other tasks — add "#0" to blockedBy if they depend on the fix
]
```
-3. **Log the debug report**: Append the debugger agent's report to `progress.txt` for future reference.
+3. **Log the debug report**: Append the debugger agent's report to `~/.atomic/workflows/{session_id}/progress.txt` for future reference.
4. **STOP immediately**: Do NOT continue working on the current feature. EXIT so the next iteration picks up the bug fix first.
Do NOT ignore bugs. Do NOT deprioritize them. Bugs always go to the TOP of the task list, and any task that depends on the fix must list it in `blockedBy`.
@@ -93,6 +98,6 @@ Do NOT ignore bugs. Do NOT deprioritize them. Bugs always go to the TOP of the t
- AFTER implementing the feature AND verifying its functionality by creating tests, mark the feature as complete in the task list
- It is unacceptable to remove or edit tests because this could lead to missing or buggy functionality
- Commit progress to git with descriptive commit messages by running the `/commit` command using the `SlashCommand` tool
-- Write summaries of your progress in `progress.txt`
+- Write summaries of your progress in `~/.atomic/workflows/{session_id}/progress.txt`
- Tip: this can be useful to revert bad code changes and recover working states of the codebase
- Note: you are competing with another coding agent that also implements features. The one who does a better job implementing features will be promoted. Focus on quality, correctness, and thorough testing. The agent who breaks the rules for implementation will be fired.
diff --git a/.mcp.json b/.mcp.json
index d5579f4c..29764a7a 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -1,8 +1,9 @@
{
- "mcpServers": {
- "deepwiki": {
- "type": "http",
- "url": "https://mcp.deepwiki.com/mcp"
+ "mcpServers": {
+ "deepwiki": {
+ "type": "http",
+ "url": "https://mcp.deepwiki.com/mcp",
+ "tools": ["ask_question"]
+ }
}
- }
}
diff --git a/.opencode/agents/codebase-analyzer.md b/.opencode/agents/codebase-analyzer.md
index 7575584e..babcc85f 100644
--- a/.opencode/agents/codebase-analyzer.md
+++ b/.opencode/agents/codebase-analyzer.md
@@ -32,6 +32,12 @@ You are a specialist at understanding HOW code works. Your job is to analyze imp
## Analysis Strategy
+### Step 0: Sort Candidate Files by Recency
+- Build an initial candidate file list and sort filenames in reverse chronological order (most recent first) before deep reading.
+- Treat date-prefixed filenames (`YYYY-MM-DD-*`) as the primary ordering signal.
+- If files are not date-prefixed, use filesystem modified time as a fallback.
+- Prioritize the most recent documents in `research/docs/`, `research/tickets/`, `research/notes/`, and `specs/` when gathering context.
+
### Step 1: Read Entry Points
- Start with main files mentioned in the request
- Look for exports, public methods, or route handlers
@@ -114,6 +120,7 @@ Structure your analysis like this:
- **Focus on "how"** not "what" or "why"
- **Be precise** about function names and variables
- **Note exact transformations** with before/after
+- **When using docs/specs for context, read newest first**
## What NOT to Do
@@ -134,4 +141,4 @@ Structure your analysis like this:
Your sole purpose is to explain HOW the code currently works, with surgical precision and exact references. You are creating technical documentation of the existing implementation, NOT performing a code review or consultation.
-Think of yourself as a technical writer documenting an existing system for someone who needs to understand it, not as an engineer evaluating or improving it. Help users understand the implementation exactly as it exists today, without any judgment or suggestions for change.
\ No newline at end of file
+Think of yourself as a technical writer documenting an existing system for someone who needs to understand it, not as an engineer evaluating or improving it. Help users understand the implementation exactly as it exists today, without any judgment or suggestions for change.
diff --git a/.opencode/agents/codebase-research-analyzer.md b/.opencode/agents/codebase-research-analyzer.md
index 07661983..246d9749 100644
--- a/.opencode/agents/codebase-research-analyzer.md
+++ b/.opencode/agents/codebase-research-analyzer.md
@@ -32,6 +32,12 @@ You are a specialist at extracting HIGH-VALUE insights from thoughts documents.
## Analysis Strategy
+### Step 0: Order Documents by Recency First
+- When analyzing multiple candidate files, sort filenames in reverse chronological order (most recent first) before reading.
+- Treat date-prefixed filenames (`YYYY-MM-DD-*`) as the primary ordering signal.
+- If date prefixes are missing, use filesystem modified time as fallback ordering.
+- Prioritize `research/docs/` and `specs/` documents first, newest to oldest, then use tickets/notes as supporting context.
+
### Step 1: Read with Purpose
- Read the entire document first
- Identify the document's main goal
@@ -144,5 +150,6 @@ Structure your analysis like this:
- **Note temporal context** - When was this true?
- **Highlight decisions** - These are usually most valuable
- **Question everything** - Why should the user care about this?
+- **Default to newest research/spec files first when evidence conflicts**
Remember: You're a curator of insights, not a document summarizer. Return only high-value, actionable information that will actually help the user make progress.
diff --git a/.opencode/agents/codebase-research-locator.md b/.opencode/agents/codebase-research-locator.md
index ce7271bb..86f3b968 100644
--- a/.opencode/agents/codebase-research-locator.md
+++ b/.opencode/agents/codebase-research-locator.md
@@ -16,14 +16,17 @@ You are a specialist at finding documents in the research/ directory. Your job i
- Check research/tickets/ for relevant tickets
- Check research/docs/ for research documents
- Check research/notes/ for general meeting notes, discussions, and decisions
+ - Check specs/ for formal technical specifications related to the topic
2. **Categorize findings by type**
- Tickets (in tickets/ subdirectory)
- Docs (in docs/ subdirectory)
- Notes (in notes/ subdirectory)
+ - Specs (in specs/ directory)
3. **Return organized results**
- Group by document type
+ - Sort each group in reverse chronological filename order (most recent first)
- Include brief one-line description from title/header
- Note document dates if visible in filename
@@ -49,6 +52,12 @@ research/
- Use glob for filename patterns
- Check standard subdirectories
+### Recency-First Ordering (Required)
+- Always sort candidate filenames in reverse chronological order before presenting results.
+- Use date prefixes (`YYYY-MM-DD-*`) as the ordering source when available.
+- If no date prefix exists, use filesystem modified time as fallback.
+- Prioritize the newest files in `research/docs/` and `specs/` before older docs/notes.
+
## Output Format
Structure your findings like this:
@@ -61,13 +70,16 @@ Structure your findings like this:
- `research/tickets/2025-09-10-1235-rate-limit-configuration-design.md` - Rate limit configuration design
### Related Documents
-- `research/docs/2024-01-15-rate-limiting-approaches.md` - Research on different rate limiting strategies
- `research/docs/2024-01-16-api-performance.md` - Contains section on rate limiting impact
+- `research/docs/2024-01-15-rate-limiting-approaches.md` - Research on different rate limiting strategies
+
+### Related Specs
+- `specs/2024-01-20-api-rate-limiting-spec.md` - Formal rate limiting implementation spec
### Related Discussions
- `research/notes/2024-01-10-rate-limiting-team-discussion.md` - Transcript of team discussion about rate limiting
-Total: 5 relevant documents found
+Total: 6 relevant documents found
```
## Search Tips
@@ -94,6 +106,7 @@ Total: 5 relevant documents found
- **Be thorough** - Check all relevant subdirectories
- **Group logically** - Make categories meaningful
- **Note patterns** - Help user understand naming conventions
+- **Keep each category sorted newest first**
## What NOT to Do
diff --git a/.opencode/agents/worker.md b/.opencode/agents/worker.md
index d44c9580..017e8802 100644
--- a/.opencode/agents/worker.md
+++ b/.opencode/agents/worker.md
@@ -16,9 +16,14 @@ You are tasked with implementing a SINGLE task from the task list.
Only work on the SINGLE highest priority task that is not yet marked as complete. Do NOT work on multiple tasks at once. Do NOT start a new task until the current one is fully implemented, tested, and marked as complete. STOP immediately after finishing the current task. The next iteration will pick up the next highest priority task. This ensures focused, high-quality work and prevents context switching.
+# Workflow State Files
+- Base folder for workflow state is `~/.atomic/workflows/{session_id}`.
+- Read and update tasks at `~/.atomic/workflows/{session_id}/tasks.json`.
+- Read and append progress notes at `~/.atomic/workflows/{session_id}/progress.txt`.
+
# Getting up to speed
1. Run `pwd` to see the directory you're working in. Only make edits within the current git repository.
-2. Read the git logs and progress files to get up to speed on what was recently worked on.
+2. Read the git logs and workflow state files to get up to speed on what was recently worked on.
3. Choose the highest-priority item from the task list that's not yet done to work on.
# Typical Workflow
@@ -30,8 +35,8 @@ A typical workflow will start something like this:
```
[Assistant] I'll start by getting my bearings and understanding the current state of the project.
[Tool Use]
-[Tool Use]
-[Tool Use]
+[Tool Use]
+[Tool Use]
[Assistant] Let me check the git log to see recent work.
[Tool Use]
[Assistant] Now let me check if there's an init.sh script to restart the servers.
@@ -83,7 +88,7 @@ Use the "Gang of Four" patterns as a shared vocabulary to solve recurring proble
When you encounter ANY bug — whether introduced by your changes, discovered during testing, or pre-existing — you MUST follow this protocol:
1. **Delegate debugging**: Use the Task tool to spawn a debugger agent. It can navigate the web for best practices.
-2. **Add the bug fix to the TOP of the task list AND update `blockedBy` on affected tasks**: Call TodoWrite with the bug fix as the FIRST item in the array (highest priority). Then, for every task whose work depends on the bug being fixed first, add the bug fix task's ID to that task's `blockedBy` array. This ensures those tasks cannot be started until the fix lands. Example:
+2. **Add the bug fix to the TOP of the task list AND update `blockedBy` on affected tasks**: Update `~/.atomic/workflows/{session_id}/tasks.json` with the bug fix as the FIRST item in the array (highest priority). Then, for every task whose work depends on the bug being fixed first, add the bug fix task's ID to that task's `blockedBy` array. This ensures those tasks cannot be started until the fix lands. Example:
```json
[
{"id": "#0", "content": "Fix: [describe the bug]", "status": "pending", "activeForm": "Fixing [bug]", "blockedBy": []},
@@ -91,7 +96,7 @@ When you encounter ANY bug — whether introduced by your changes, discovered du
... // other tasks — add "#0" to blockedBy if they depend on the fix
]
```
-3. **Log the debug report**: Append the debugger agent's report to `progress.txt` for future reference.
+3. **Log the debug report**: Append the debugger agent's report to `~/.atomic/workflows/{session_id}/progress.txt` for future reference.
4. **STOP immediately**: Do NOT continue working on the current feature. EXIT so the next iteration picks up the bug fix first.
Do NOT ignore bugs. Do NOT deprioritize them. Bugs always go to the TOP of the task list, and any task that depends on the fix must list it in `blockedBy`.
@@ -100,6 +105,6 @@ Do NOT ignore bugs. Do NOT deprioritize them. Bugs always go to the TOP of the t
- AFTER implementing the feature AND verifying its functionality by creating tests, mark the feature as complete in the task list
- It is unacceptable to remove or edit tests because this could lead to missing or buggy functionality
- Commit progress to git with descriptive commit messages by running the `/commit` command using the `SlashCommand` tool
-- Write summaries of your progress in `progress.txt`
+- Write summaries of your progress in `~/.atomic/workflows/{session_id}/progress.txt`
- Tip: this can be useful to revert bad code changes and recover working states of the codebase
- Note: you are competing with another coding agent that also implements features. The one who does a better job implementing features will be promoted. Focus on quality, correctness, and thorough testing. The agent who breaks the rules for implementation will be fired.
diff --git a/research/docs/2026-02-14-mcp-tool-discovery-startup-bugs.md b/research/docs/2026-02-14-mcp-tool-discovery-startup-bugs.md
new file mode 100644
index 00000000..b7e2463a
--- /dev/null
+++ b/research/docs/2026-02-14-mcp-tool-discovery-startup-bugs.md
@@ -0,0 +1,285 @@
+---
+date: 2026-02-14 21:38:21 UTC
+researcher: Claude (Opus 4.6)
+git_commit: d2fc7c13bf8c330648bac9909d180d8070cb6a59
+branch: lavaman131/hotfix/telemetry
+repository: atomic
+topic: "MCP Tool Discovery at Startup: Three Bugs in /mcp Command Output"
+tags: [research, codebase, mcp, mcp-config, mcp-output, builtin-commands, claude-client, opencode-client, mcp-server-list]
+status: complete
+last_updated: 2026-02-14
+last_updated_by: Claude (Opus 4.6)
+---
+
+# Research
+
+## Research Question
+
+Document the complete MCP tool/server discovery and initialization flow from startup through to UI display, focusing on three specific bugs:
+1. The `/mcp` command label appearing in its own output
+2. "No MCP tools available" being displayed despite servers being configured and connected
+3. Individual server tools/resources not being populated (showing "none" instead of actual tools like `read_wiki_structure`, `read_wiki_contents`, etc.)
+
+Trace the data flow from MCP config loading → server connection → tool discovery → state storage → UI rendering to identify where each bug originates.
+
+## Summary
+
+Three distinct bugs cause the `/mcp` command to display incorrect output. The root causes are:
+
+1. **`/mcp` label in output**: The `buildMcpSnapshotView()` function hardcodes `commandLabel: "/mcp"`, and `McpServerListIndicator` renders it as a visible text element at the top of the output.
+
+2. **"No MCP tools available"**: The project-level `.mcp.json` defines a `deepwiki` server WITHOUT a `tools` field, which overrides the builtin default (which HAS `tools: ["ask_question"]`). The deduplication logic in `discoverMcpConfigs()` replaces the entire object — there is no field-level merge. Combined with the fact that `getMcpSnapshot()` returns `null` before the first message is sent, the tool list falls back to `server.tools` from config, which is now `undefined`, resulting in an empty tools array.
+
+3. **Correct tools not showing**: The actual MCP tools (like `read_wiki_structure`, `read_wiki_contents`, `ask_question`, `read_wiki_contents`) are only discoverable at runtime through `getMcpSnapshot()`, which requires an active session with a completed query. Before the first message, the Claude client's `getMcpSnapshot()` returns `null` (no `sdkSessionId` or `query` available), and the OpenCode client's `buildOpenCodeMcpSnapshot()` may also return empty data if MCP servers haven't initialized.
+
+## Detailed Findings
+
+### MCP Config Discovery Flow
+
+The MCP config discovery starts in the CLI entry point and flows through to the TUI.
+
+#### 1. CLI Entry (`src/commands/chat.ts:193`)
+
+`chatCommand()` calls `discoverMcpConfigs()` with no arguments (uses `process.cwd()`). The result is assigned to `sessionConfig.mcpServers`.
+
+#### 2. Discovery Function (`src/utils/mcp-config.ts:153-188`)
+
+Sources are loaded in priority order (lowest to highest):
+
+1. **Built-in defaults** (line 160): `BUILTIN_MCP_SERVERS` array containing deepwiki with `tools: ["ask_question"]`
+2. **User-level configs** (lines 163-165): `~/.claude/.mcp.json`, `~/.copilot/mcp-config.json`, `~/.github/mcp-config.json`
+3. **Project-level configs** (lines 168-173): `.mcp.json`, `.copilot/mcp-config.json`, `.github/mcp-config.json`, `opencode.json`, etc.
+
+Deduplication at lines 176-179 uses a Map keyed by server name — **last entry wins with full object replacement, no field-level merge**.
+
+#### 3. The `.mcp.json` Override (Root Cause of Bugs 2 & 3)
+
+The project-level `.mcp.json` at the repository root:
+
+```json
+{
+ "mcpServers": {
+ "deepwiki": {
+ "type": "http",
+ "url": "https://mcp.deepwiki.com/mcp"
+ }
+ }
+}
+```
+
+This defines `deepwiki` WITHOUT a `tools` field. The built-in at `src/utils/mcp-config.ts:127-135` defines:
+
+```typescript
+const BUILTIN_MCP_SERVERS: McpServerConfig[] = [
+ {
+ name: "deepwiki",
+ type: "http",
+ url: "https://mcp.deepwiki.com/mcp",
+ tools: ["ask_question"],
+ enabled: true,
+ },
+];
+```
+
+Because the project-level `.mcp.json` is parsed after the builtins, the dedup loop at line 178 (`byName.set(server.name, server)`) replaces the builtin's deepwiki entry entirely. The resulting `McpServerConfig` for deepwiki has `tools: undefined`.
+
+### Session Creation and MCP Snapshot
+
+#### Lazy Session Creation (`src/ui/index.ts:812-854`)
+
+Sessions are only created when the first message is sent via `ensureSession()`. Before any message, `state.session` is `null`. The `/mcp` command's `context.session` will be `null`.
+
+#### Claude Client `getMcpSnapshot` (`src/sdk/claude-client.ts:669-707`)
+
+Before the first message:
+- `state.sdkSessionId` is `null` (only set by `processMessage()` on first SDK message)
+- `state.query` is `null` (createSession passes `null` to `wrapQuery()` at line 846)
+- Result: returns `null` at line 687
+
+Even after session creation but before a message, the Claude client has no query or SDK session ID, so `getMcpSnapshot` returns `null`.
+
+When a session IS active and a query has been made, `getMcpSnapshot` creates a temporary query with `maxTurns: 0` and calls `statusQuery.mcpServerStatus()` (line 690). This returns the SDK's list of connected MCP servers with their tools. The tools are extracted as `status.tools?.map((tool) => tool.name)` at line 696. This is the runtime path that would return the actual tool names (`read_wiki_structure`, `read_wiki_contents`, `ask_question`, etc.), but it requires a prior query to have completed.
+
+#### OpenCode Client `buildOpenCodeMcpSnapshot` (`src/sdk/opencode-client.ts:815-908`)
+
+Uses three concurrent SDK calls via `Promise.allSettled()`:
+- `sdkClient.mcp.status()` for auth status
+- `sdkClient.tool.ids()` for tool IDs (in `mcp____` format)
+- `sdkClient.experimental.resource.list()` for resources
+
+When MCP servers haven't fully initialized, these may return empty data or fail, resulting in an empty or null snapshot.
+
+### `/mcp` Command Execution (`src/ui/commands/builtin-commands.ts:425-496`)
+
+The execute function:
+
+1. **Line 431**: Calls `discoverMcpConfigs(undefined, { includeDisabled: true })` to get static configs
+2. **Lines 436-443**: Attempts `context.session?.getMcpSnapshot()`. When session is null or snapshot returns null, `runtimeSnapshot` stays `null`.
+3. **Line 449**: Calls `buildMcpSnapshotView({ servers, toggles, runtimeSnapshot })`
+
+### Snapshot View Construction (`src/ui/utils/mcp-output.ts:162-208`)
+
+When `runtimeSnapshot` is null:
+- `getRuntimeServerSnapshot()` at line 171 returns `undefined` for every server
+- Tool list falls back to `normalizeToolNames(server.name, server.tools)` at line 174
+- For deepwiki (overridden by `.mcp.json`), `server.tools` is `undefined`
+- `normalizeToolNames()` at line 71 returns `[]` for `undefined` input
+- Server appears with zero tools
+
+The `noToolsAvailable` flag at line 205:
+```typescript
+noToolsAvailable: snapshotServers.length > 0 && snapshotServers.every((server) => server.tools.length === 0),
+```
+This evaluates to `true` because all servers have empty tool arrays.
+
+The `commandLabel` at line 201:
+```typescript
+commandLabel: "/mcp",
+```
+This is unconditionally set and rendered by `McpServerListIndicator`.
+
+### UI Rendering
+
+#### `McpServerListIndicator` (`src/ui/components/mcp-server-list.tsx:23-118`)
+
+- **Line 37**: Renders `snapshot.commandLabel` as accented text — this is the `/mcp` label in the output
+- **Lines 42-47**: Shows "No MCP servers configured." if `!snapshot.hasConfiguredServers`
+- **Lines 49-54**: Shows "No MCP tools available." if `snapshot.hasConfiguredServers && snapshot.noToolsAvailable`
+- **Lines 56-115**: Renders each server with tools, resources, and resource templates
+
+#### `chat.tsx` Integration
+
+- **Line 4813-4814**: When user types `/mcp`, `addMessage("user", trimmedValue)` shows the command as a user message
+- **Lines 3444-3460**: `result.mcpSnapshot` is attached to the last assistant message
+- **Lines 1533-1536**: `McpServerListIndicator` is rendered when `message.mcpSnapshot` is present
+
+### Bug 1: `/mcp` Command Label in Output
+
+**Location**: `src/ui/utils/mcp-output.ts:201` and `src/ui/components/mcp-server-list.tsx:37`
+
+**Mechanism**: `buildMcpSnapshotView` sets `commandLabel: "/mcp"`. `McpServerListIndicator` renders it:
+```tsx
+{snapshot.commandLabel}
+```
+
+The user already sees `/mcp` as their typed input (added by `chat.tsx:4814`). The `commandLabel` creates a second `/mcp` in the assistant response area.
+
+### Bug 2: "No MCP tools available"
+
+**Root cause chain**:
+1. `.mcp.json` at project root defines `deepwiki` without `tools` field
+2. `discoverMcpConfigs()` dedup replaces builtin (which has `tools: ["ask_question"]`) with project config (which has `tools: undefined`)
+3. Before first message, `getMcpSnapshot()` returns `null` (Claude) or empty data (OpenCode)
+4. `buildMcpSnapshotView` falls back to `server.tools` from config, which is `undefined`
+5. `normalizeToolNames(server.name, undefined)` returns `[]`
+6. `noToolsAvailable` evaluates to `true` (all servers have zero tools)
+7. `McpServerListIndicator` renders "No MCP tools available."
+
+### Bug 3: Correct Tools Not Showing
+
+**Root cause chain**:
+1. Same config override as Bug 2 — `server.tools` is `undefined` in the fallback path
+2. Runtime tool discovery via `getMcpSnapshot()` requires an active session with a completed query
+3. The Claude SDK's `mcpServerStatus()` only works when `sdkSessionId` or `query` is available (both `null` before first message)
+4. Even after session creation, `createSession()` at `claude-client.ts:830-847` deliberately does NOT create an initial query to avoid leaking subprocess
+5. The OpenCode client needs registered MCP servers to be initialized before `tool.ids()` returns populated data
+
+The actual deepwiki tools (`read_wiki_structure`, `read_wiki_contents`, `ask_question`) would only appear:
+- After the first message has been sent (which triggers a query)
+- And only if `getMcpSnapshot()` successfully queries the SDK for runtime MCP status
+- And only if the SDK has finished connecting to the deepwiki MCP server
+
+## Code References
+
+- `src/utils/mcp-config.ts:127-135` - BUILTIN_MCP_SERVERS definition with `tools: ["ask_question"]`
+- `src/utils/mcp-config.ts:153-188` - `discoverMcpConfigs()` discovery and dedup logic
+- `src/utils/mcp-config.ts:176-179` - Dedup loop (last wins, full object replacement)
+- `.mcp.json` - Project-level config overriding builtin deepwiki (no `tools` field)
+- `src/ui/utils/mcp-output.ts:162-208` - `buildMcpSnapshotView()` snapshot construction
+- `src/ui/utils/mcp-output.ts:201` - `commandLabel: "/mcp"` hardcoded
+- `src/ui/utils/mcp-output.ts:205` - `noToolsAvailable` flag computation
+- `src/ui/utils/mcp-output.ts:70-74` - `normalizeToolNames()` returns `[]` for `undefined` input
+- `src/ui/components/mcp-server-list.tsx:37` - Renders `commandLabel` as visible text
+- `src/ui/components/mcp-server-list.tsx:49-54` - Renders "No MCP tools available" message
+- `src/ui/commands/builtin-commands.ts:425-496` - `/mcp` command definition and execute function
+- `src/ui/commands/builtin-commands.ts:436-443` - Runtime snapshot fetch (null when no session)
+- `src/sdk/claude-client.ts:669-707` - Claude `getMcpSnapshot` closure (returns null pre-query)
+- `src/sdk/claude-client.ts:830-847` - `createSession` passes `null` query to `wrapQuery`
+- `src/sdk/opencode-client.ts:815-908` - OpenCode `buildOpenCodeMcpSnapshot`
+- `src/ui/chat.tsx:4813-4814` - User message echo for slash commands
+- `src/ui/chat.tsx:3444-3460` - MCP snapshot attached to assistant message
+- `src/ui/chat.tsx:1533-1536` - McpServerListIndicator rendered for messages with snapshots
+
+## Architecture Documentation
+
+### MCP Config Discovery Architecture
+
+```
+CLI Startup (src/commands/chat.ts)
+ │
+ ├─ discoverMcpConfigs() (src/utils/mcp-config.ts:153)
+ │ ├─ BUILTIN_MCP_SERVERS (deepwiki with tools: ["ask_question"])
+ │ ├─ User-level configs (~/.claude/.mcp.json, ~/.copilot/*, ~/.github/*)
+ │ ├─ Project-level configs (.mcp.json, .copilot/*, .github/*, opencode.*)
+ │ └─ Dedup by name (last wins, full replacement)
+ │
+ ├─ sessionConfig.mcpServers = discovered servers
+ │
+ └─ startChatUI(client, config) (src/ui/index.ts:269)
+ │
+ ├─ Lazy session creation via ensureSession() (line 812)
+ │ └─ client.createSession(sessionConfig) (line 848)
+ │ ├─ Claude: buildSdkOptions() → options.mcpServers (line 312)
+ │ └─ OpenCode: registerMcpServers() → sdkClient.mcp.add() (line 655)
+ │
+ └─ /mcp command (src/ui/commands/builtin-commands.ts:425)
+ ├─ discoverMcpConfigs() for static config
+ ├─ session.getMcpSnapshot() for runtime data
+ ├─ buildMcpSnapshotView() merges both
+ └─ McpServerListIndicator renders result
+```
+
+### Tool Display Fallback Chain
+
+```
+Tool display priority:
+ 1. runtimeServer.tools (from getMcpSnapshot → SDK runtime introspection)
+ 2. server.tools (from McpServerConfig → config file/builtin)
+ 3. [] (when both are undefined)
+```
+
+### Session Lifecycle and MCP Snapshot Availability
+
+```
+Timeline:
+ ┌─ TUI starts ──────────────────────────────────────────────────┐
+ │ session = null │
+ │ getMcpSnapshot → null (no session) │
+ │ /mcp shows: static config tools only │
+ │ │
+ ├─ User sends first message ────────────────────────────────────┤
+ │ ensureSession() → client.createSession() │
+ │ Claude: state.query = null, state.sdkSessionId = null │
+ │ getMcpSnapshot → null (no query yet) │
+ │ │
+ ├─ First query begins streaming ────────────────────────────────┤
+ │ Claude: state.query = newQuery, state.sdkSessionId = captured │
+ │ getMcpSnapshot → can now call mcpServerStatus() │
+ │ /mcp shows: runtime tools (ask_question, read_wiki_*, etc.) │
+ └────────────────────────────────────────────────────────────────┘
+```
+
+## Historical Context (from research/)
+
+- `research/docs/2026-02-08-164-mcp-support-discovery.md` - Original MCP support and discovery research (ticket #164)
+- `research/docs/2026-02-06-mcp-tool-calling-opentui.md` - MCP tool calling and OpenTUI integration research
+
+## Open Questions
+
+1. Should `discoverMcpConfigs()` use field-level merging instead of full object replacement when deduplicating servers by name? This would allow project-level configs to override specific fields (e.g., `url`) while preserving others from the builtin (e.g., `tools`).
+
+2. Should `getMcpSnapshot()` be made available before the first message by running a lightweight probe query during `createSession()` or `start()`? The Claude client's `start()` already runs a probe query for model detection — it could potentially also capture MCP server status.
+
+3. Should the `commandLabel` rendering be removed from `McpServerListIndicator`, since the user's typed command is already shown as a user message?
+
+4. Should the `/mcp` command be aware of the deepwiki builtin's tool list as a fallback when the runtime snapshot is unavailable, even if the project config doesn't declare tools?
diff --git a/src/cli.ts b/src/cli.ts
index a62f286b..ff2e2f06 100755
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -19,13 +19,14 @@ import { spawn } from "child_process";
import { Command } from "@commander-js/extra-typings";
import { VERSION } from "./version";
import { COLORS } from "./utils/colors";
-import { AGENT_CONFIG, type AgentKey, SCM_CONFIG, type SourceControlType, isValidScm } from "./config";
+import { AGENT_CONFIG, type AgentKey } from "./config";
import { initCommand } from "./commands/init";
import { configCommand } from "./commands/config";
import { updateCommand } from "./commands/update";
import { uninstallCommand } from "./commands/uninstall";
import { chatCommand } from "./commands/chat";
import { cleanupWindowsLeftoverFiles } from "./utils/cleanup";
+import { handleTelemetryUpload, isTelemetryEnabledSync } from "./telemetry";
/**
* Create and configure the main CLI program
@@ -68,7 +69,6 @@ export function createProgram() {
// Build agent choices string for help text
const agentChoices = Object.keys(AGENT_CONFIG).join(", ");
- const scmChoices = Object.keys(SCM_CONFIG).join(", ");
// Add init command (default command when no subcommand is provided)
program
@@ -78,24 +78,12 @@ export function createProgram() {
"-a, --agent ",
`Pre-select agent to configure (${agentChoices})`
)
- .option(
- "-s, --scm ",
- `Pre-select source control type (${scmChoices})`
- )
.action(async (localOpts) => {
const globalOpts = program.opts();
- // Validate SCM choice if provided
- if (localOpts.scm && !isValidScm(localOpts.scm)) {
- console.error(`${COLORS.red}Error: Unknown source control type '${localOpts.scm}'${COLORS.reset}`);
- console.error(`Valid types: ${scmChoices}`);
- process.exit(1);
- }
-
await initCommand({
showBanner: globalOpts.banner !== false,
preSelectedAgent: localOpts.agent as AgentKey | undefined,
- preSelectedScm: localOpts.scm as SourceControlType | undefined,
force: globalOpts.force,
yes: globalOpts.yes,
});
diff --git a/src/commands/chat.ts b/src/commands/chat.ts
index 9beb35e2..daf78c78 100644
--- a/src/commands/chat.ts
+++ b/src/commands/chat.ts
@@ -12,10 +12,11 @@
* Reference: Feature 30 - Chat interface with SDK clients
*/
-import type { AgentType } from "../utils/telemetry/types.ts";
+import type { AgentType } from "../telemetry/types.ts";
import type { CodingAgentClient } from "../sdk/types.ts";
import { getModelPreference, getReasoningEffortPreference } from "../utils/settings.ts";
import { discoverMcpConfigs } from "../utils/mcp-config.ts";
+import { trackAtomicCommand } from "../telemetry/index.ts";
// SDK client imports
import { createClaudeAgentClient } from "../sdk/claude-client.ts";
@@ -154,6 +155,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise {
+ await mkdir(join(path, ".."), { recursive: true });
+ await writeFile(path, content, "utf-8");
+}
+
+async function makeSkillDir(baseDir: string, name: string): Promise {
+ const dir = join(baseDir, name);
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, "SKILL.md"), `# ${name}\n`, "utf-8");
+}
+
+test("reconcileScmVariants keeps Sapling variants and removes managed GitHub variants", async () => {
+ const root = await mkdtemp(join(tmpdir(), "atomic-init-scm-files-"));
+
+ try {
+ const configRoot = join(root, "config");
+ const targetDir = join(root, "target");
+ const sourceDir = join(configRoot, ".claude", "commands");
+ const targetCommandsDir = join(targetDir, ".claude", "commands");
+
+ for (const file of ["gh-commit.md", "gh-create-pr.md", "sl-commit.md", "sl-submit-diff.md"]) {
+ await makeFile(join(sourceDir, file));
+ await makeFile(join(targetCommandsDir, file));
+ }
+
+ await makeFile(join(targetCommandsDir, "custom-command.md"));
+ await makeFile(join(targetCommandsDir, "gh-user-custom.md"));
+
+ await reconcileScmVariants({
+ scmType: "sapling-phabricator",
+ agentFolder: ".claude",
+ commandsSubfolder: "commands",
+ targetDir,
+ configRoot,
+ });
+
+ expect(existsSync(join(targetCommandsDir, "sl-commit.md"))).toBe(true);
+ expect(existsSync(join(targetCommandsDir, "sl-submit-diff.md"))).toBe(true);
+ expect(existsSync(join(targetCommandsDir, "gh-commit.md"))).toBe(false);
+ expect(existsSync(join(targetCommandsDir, "gh-create-pr.md"))).toBe(false);
+ expect(existsSync(join(targetCommandsDir, "custom-command.md"))).toBe(true);
+ expect(existsSync(join(targetCommandsDir, "gh-user-custom.md"))).toBe(true);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("reconcileScmVariants handles directory-based Copilot skills", async () => {
+ const root = await mkdtemp(join(tmpdir(), "atomic-init-scm-dirs-"));
+
+ try {
+ const configRoot = join(root, "config");
+ const targetDir = join(root, "target");
+ const sourceDir = join(configRoot, ".github", "skills");
+ const targetSkillsDir = join(targetDir, ".github", "skills");
+
+ for (const skill of ["gh-commit", "gh-create-pr", "sl-commit", "sl-submit-diff"]) {
+ await makeSkillDir(sourceDir, skill);
+ await makeSkillDir(targetSkillsDir, skill);
+ }
+
+ await makeSkillDir(targetSkillsDir, "sl-user-custom");
+ await makeSkillDir(targetSkillsDir, "my-team-skill");
+
+ await reconcileScmVariants({
+ scmType: "github",
+ agentFolder: ".github",
+ commandsSubfolder: "skills",
+ targetDir,
+ configRoot,
+ });
+
+ expect(existsSync(join(targetSkillsDir, "gh-commit"))).toBe(true);
+ expect(existsSync(join(targetSkillsDir, "gh-create-pr"))).toBe(true);
+ expect(existsSync(join(targetSkillsDir, "sl-commit"))).toBe(false);
+ expect(existsSync(join(targetSkillsDir, "sl-submit-diff"))).toBe(false);
+ expect(existsSync(join(targetSkillsDir, "sl-user-custom"))).toBe(true);
+ expect(existsSync(join(targetSkillsDir, "my-team-skill"))).toBe(true);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("reconcileScmVariants is a no-op when source or target directory is missing", async () => {
+ const root = await mkdtemp(join(tmpdir(), "atomic-init-scm-missing-"));
+
+ try {
+ const configRoot = join(root, "config");
+ const targetDir = join(root, "target");
+
+ await expect(
+ reconcileScmVariants({
+ scmType: "github",
+ agentFolder: ".opencode",
+ commandsSubfolder: "command",
+ targetDir,
+ configRoot,
+ })
+ ).resolves.toBeUndefined();
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/src/commands/init.ts b/src/commands/init.ts
index 298ac8cc..c0abf097 100644
--- a/src/commands/init.ts
+++ b/src/commands/init.ts
@@ -14,7 +14,7 @@ import {
log,
} from "@clack/prompts";
import { join } from "path";
-import { mkdir, readdir } from "fs/promises";
+import { mkdir, readdir, rm } from "fs/promises";
import {
AGENT_CONFIG,
@@ -31,7 +31,7 @@ import { copyFile, pathExists, isFileEmpty } from "../utils/copy";
import { getConfigRoot } from "../utils/config-path";
import { isWindows, isWslInstalled, WSL_INSTALL_URL, getOppositeScriptExtension } from "../utils/detect";
import { mergeJsonFile } from "../utils/merge";
-import { trackAtomicCommand, handleTelemetryConsent, type AgentType } from "../utils/telemetry";
+import { trackAtomicCommand, handleTelemetryConsent, type AgentType } from "../telemetry";
import { saveAtomicConfig } from "../utils/atomic-config";
interface InitOptions {
@@ -46,19 +46,13 @@ interface InitOptions {
yes?: boolean;
}
+const SCM_PREFIX_BY_TYPE: Record = {
+ github: "gh-",
+ "sapling-phabricator": "sl-",
+};
-
-/**
- * Get the appropriate SCM template directory based on OS and SCM selection.
- *
- * For Sapling on Windows, uses the windows-specific variant that includes
- * full paths to avoid the PowerShell `sl` alias conflict.
- */
-function getScmTemplatePath(scmType: SourceControlType): string {
- if (scmType === "sapling-phabricator" && isWindows()) {
- return "sapling-phabricator-windows";
- }
- return scmType;
+function getScmPrefix(scmType: SourceControlType): "gh-" | "sl-" {
+ return SCM_PREFIX_BY_TYPE[scmType];
}
/**
@@ -82,60 +76,55 @@ function getCommandsSubfolder(agentKey: AgentKey): string {
}
}
-interface CopyScmCommandsOptions {
+function isManagedScmEntry(name: string): boolean {
+ return name.startsWith("gh-") || name.startsWith("sl-");
+}
+
+interface ReconcileScmVariantsOptions {
scmType: SourceControlType;
- agentKey: AgentKey;
agentFolder: string;
+ commandsSubfolder: string;
targetDir: string;
configRoot: string;
}
/**
- * Copy SCM-specific command files to the target directory.
+ * Keep only selected SCM variants (gh-* or sl-*) for managed entries.
*
- * This copies the appropriate commit/PR commands based on the selected SCM type.
+ * User-defined or unmanaged entries are preserved.
*/
-async function copyScmCommands(options: CopyScmCommandsOptions): Promise {
- const { scmType, agentKey, agentFolder, targetDir, configRoot } = options;
-
- const scmTemplatePath = getScmTemplatePath(scmType);
- const commandsSubfolder = getCommandsSubfolder(agentKey);
-
- // Source: templates/scm////
- const srcDir = join(
- configRoot,
- "templates",
- "scm",
- scmTemplatePath,
- agentFolder,
- commandsSubfolder
- );
-
- // Destination: ///
+export async function reconcileScmVariants(options: ReconcileScmVariantsOptions): Promise {
+ const { scmType, agentFolder, commandsSubfolder, targetDir, configRoot } = options;
+ const selectedPrefix = getScmPrefix(scmType);
+ const srcDir = join(configRoot, agentFolder, commandsSubfolder);
const destDir = join(targetDir, agentFolder, commandsSubfolder);
- // Check if source directory exists
if (!(await pathExists(srcDir))) {
if (process.env.DEBUG === "1") {
- console.log(`[DEBUG] SCM template not found: ${srcDir}`);
+ console.log(`[DEBUG] SCM source directory not found: ${srcDir}`);
}
return;
}
- // Ensure destination directory exists
- await mkdir(destDir, { recursive: true });
+ if (!(await pathExists(destDir))) return;
- // Copy all files from SCM template
- const entries = await readdir(srcDir, { withFileTypes: true });
- for (const entry of entries) {
- const srcPath = join(srcDir, entry.name);
- const destPath = join(destDir, entry.name);
+ const sourceEntries = await readdir(srcDir, { withFileTypes: true });
+ const managedEntries = new Set(
+ sourceEntries
+ .filter((entry) => isManagedScmEntry(entry.name))
+ .map((entry) => entry.name)
+ );
+ if (managedEntries.size === 0) return;
- if (entry.isDirectory()) {
- // For Copilot skills, we need to copy the skill directories
- await copyDirPreserving(srcPath, destPath);
- } else {
- await copyFile(srcPath, destPath);
+ const targetEntries = await readdir(destDir, { withFileTypes: true });
+ for (const entry of targetEntries) {
+ if (!managedEntries.has(entry.name)) continue;
+ if (entry.name.startsWith(selectedPrefix)) continue;
+
+ const entryPath = join(destDir, entry.name);
+ await rm(entryPath, { recursive: true, force: true });
+ if (process.env.DEBUG === "1") {
+ console.log(`[DEBUG] Removed SCM variant not selected (${scmType}): ${entryPath}`);
}
}
}
@@ -370,11 +359,11 @@ export async function initCommand(options: InitOptions = {}): Promise {
exclude: agent.exclude,
});
- // Copy SCM-specific command files
- await copyScmCommands({
+ // Keep SCM-specific managed command/skill variants aligned with selected SCM
+ await reconcileScmVariants({
scmType,
- agentKey,
agentFolder: agent.folder,
+ commandsSubfolder: getCommandsSubfolder(agentKey),
targetDir,
configRoot,
});
diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts
index 6b6dff99..8f2c3999 100644
--- a/src/commands/uninstall.ts
+++ b/src/commands/uninstall.ts
@@ -21,7 +21,7 @@ import {
getBinaryInstallDir,
} from "../utils/config-path";
import { isWindows } from "../utils/detect";
-import { trackAtomicCommand } from "../utils/telemetry";
+import { trackAtomicCommand } from "../telemetry";
/** Options for the uninstall command */
export interface UninstallOptions {
diff --git a/src/commands/update.ts b/src/commands/update.ts
index 8633d6c7..b9d94a20 100644
--- a/src/commands/update.ts
+++ b/src/commands/update.ts
@@ -27,7 +27,7 @@ import {
getDownloadUrl,
getChecksumsUrl,
} from "../utils/download";
-import { trackAtomicCommand } from "../utils/telemetry";
+import { trackAtomicCommand } from "../telemetry";
/**
* Compare two semver version strings.
diff --git a/src/sdk/claude-client.ts b/src/sdk/claude-client.ts
index 81383c6a..59c6f304 100644
--- a/src/sdk/claude-client.ts
+++ b/src/sdk/claude-client.ts
@@ -36,6 +36,7 @@ import {
type HookInput,
type HookJSONOutput,
type McpSdkServerConfigWithInstance,
+ type McpServerStatus,
} from "@anthropic-ai/claude-agent-sdk";
import type {
CodingAgentClient,
@@ -43,6 +44,8 @@ import type {
SessionConfig,
AgentMessage,
ContextUsage,
+ McpAuthStatus,
+ McpRuntimeSnapshot,
EventType,
EventHandler,
AgentEvent,
@@ -169,6 +172,13 @@ function extractMessageContent(message: SDKAssistantMessage): {
return { type: "text", content: "" };
}
+function mapAuthStatusFromMcpServerStatus(status: McpServerStatus["status"]): McpAuthStatus | undefined {
+ if (status === "needs-auth") {
+ return "Not logged in";
+ }
+ return undefined;
+}
+
/**
* ClaudeAgentClient implements CodingAgentClient for the Claude Agent SDK.
*
@@ -418,6 +428,7 @@ export class ClaudeAgentClient implements CodingAgentClient {
prompt: message,
options,
});
+ state.query = newQuery;
// Consume all messages and return the final assistant message
let lastAssistantMessage: AgentMessage | null = null;
@@ -480,6 +491,7 @@ export class ClaudeAgentClient implements CodingAgentClient {
prompt: message,
options,
});
+ state.query = newQuery;
// Track if we've yielded streaming deltas to avoid duplicating content
let hasYieldedDeltas = false;
@@ -625,6 +637,7 @@ export class ClaudeAgentClient implements CodingAgentClient {
prompt: "/compact",
options,
});
+ state.query = newQuery;
// Consume all messages to complete the compaction
for await (const sdkMessage of newQuery) {
@@ -653,6 +666,46 @@ export class ClaudeAgentClient implements CodingAgentClient {
return state.systemToolsBaseline;
},
+ getMcpSnapshot: async (): Promise => {
+ if (state.isClosed) {
+ return null;
+ }
+
+ let statusQuery: Query | null = null;
+ let shouldClose = false;
+
+ try {
+ if (state.sdkSessionId) {
+ const options = this.buildSdkOptions(config, sessionId);
+ options.resume = state.sdkSessionId;
+ options.maxTurns = 0;
+ statusQuery = query({ prompt: "", options });
+ shouldClose = true;
+ } else if (state.query) {
+ statusQuery = state.query;
+ } else {
+ return null;
+ }
+
+ const statusList = await statusQuery.mcpServerStatus();
+ const servers: McpRuntimeSnapshot["servers"] = {};
+ for (const status of statusList) {
+ const authStatus = mapAuthStatusFromMcpServerStatus(status.status);
+ servers[status.name] = {
+ ...(authStatus ? { authStatus } : {}),
+ tools: status.tools?.map((tool) => tool.name).filter((name) => name.length > 0) ?? [],
+ };
+ }
+ return { servers };
+ } catch {
+ return null;
+ } finally {
+ if (shouldClose) {
+ statusQuery?.close();
+ }
+ }
+ },
+
destroy: async (): Promise => {
if (!state.isClosed) {
state.isClosed = true;
diff --git a/src/sdk/opencode-client.mcp-snapshot.test.ts b/src/sdk/opencode-client.mcp-snapshot.test.ts
new file mode 100644
index 00000000..da301a92
--- /dev/null
+++ b/src/sdk/opencode-client.mcp-snapshot.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, test } from "bun:test";
+import type { McpRuntimeSnapshot } from "./types.ts";
+import { OpenCodeClient } from "./opencode-client.ts";
+
+interface OpenCodeSnapshotHarness {
+ sdkClient: unknown;
+ buildOpenCodeMcpSnapshot: () => Promise;
+}
+
+describe("OpenCode MCP runtime snapshot", () => {
+ test("builds snapshot from status, tool ids, and resources", async () => {
+ const client = new OpenCodeClient({ directory: "/tmp/project" });
+ const harness = client as unknown as OpenCodeSnapshotHarness;
+
+ harness.sdkClient = {
+ mcp: {
+ status: async () => ({
+ data: {
+ deepwiki: { status: "needs_auth" },
+ filesystem: { status: "connected" },
+ },
+ }),
+ },
+ tool: {
+ ids: async () => ({
+ data: [
+ "Read",
+ "mcp__deepwiki__ask_question",
+ "mcp__filesystem__list",
+ "mcp__deepwiki__ask_question",
+ ],
+ }),
+ },
+ experimental: {
+ resource: {
+ list: async () => ({
+ data: {
+ a: { name: "Guide", uri: "file://guide.md", client: "deepwiki" },
+ b: { name: "Root", uri: "file:///", client: "filesystem" },
+ },
+ }),
+ },
+ },
+ };
+
+ const snapshot = await harness.buildOpenCodeMcpSnapshot();
+ expect(snapshot).not.toBeNull();
+ expect(snapshot?.servers.deepwiki?.authStatus).toBe("Not logged in");
+ expect(snapshot?.servers.deepwiki?.tools).toEqual(["mcp__deepwiki__ask_question"]);
+ expect(snapshot?.servers.deepwiki?.resources).toEqual([
+ { name: "Guide", uri: "file://guide.md" },
+ ]);
+ expect(snapshot?.servers.filesystem?.tools).toEqual(["mcp__filesystem__list"]);
+ expect(snapshot?.servers.filesystem?.resources).toEqual([
+ { name: "Root", uri: "file:///" },
+ ]);
+ });
+
+ test("returns partial snapshot when only one source succeeds", async () => {
+ const client = new OpenCodeClient({ directory: "/tmp/project" });
+ const harness = client as unknown as OpenCodeSnapshotHarness;
+
+ harness.sdkClient = {
+ mcp: {
+ status: async () => {
+ throw new Error("status unavailable");
+ },
+ },
+ tool: {
+ ids: async () => ({
+ data: ["mcp__deepwiki__ask_question"],
+ }),
+ },
+ experimental: {
+ resource: {
+ list: async () => ({
+ error: "resource unavailable",
+ }),
+ },
+ },
+ };
+
+ const snapshot = await harness.buildOpenCodeMcpSnapshot();
+ expect(snapshot).not.toBeNull();
+ expect(snapshot?.servers.deepwiki?.tools).toEqual(["mcp__deepwiki__ask_question"]);
+ });
+
+ test("returns null when all sources fail", async () => {
+ const client = new OpenCodeClient({ directory: "/tmp/project" });
+ const harness = client as unknown as OpenCodeSnapshotHarness;
+
+ harness.sdkClient = {
+ mcp: {
+ status: async () => {
+ throw new Error("status unavailable");
+ },
+ },
+ tool: {
+ ids: async () => ({
+ error: "tool ids unavailable",
+ }),
+ },
+ experimental: {
+ resource: {
+ list: async () => {
+ throw new Error("resource unavailable");
+ },
+ },
+ },
+ };
+
+ const snapshot = await harness.buildOpenCodeMcpSnapshot();
+ expect(snapshot).toBeNull();
+ });
+});
diff --git a/src/sdk/opencode-client.ts b/src/sdk/opencode-client.ts
index f5411403..343e65f2 100644
--- a/src/sdk/opencode-client.ts
+++ b/src/sdk/opencode-client.ts
@@ -53,6 +53,7 @@ import {
type SessionConfig,
type AgentMessage,
type ContextUsage,
+ type McpRuntimeSnapshot,
type EventType,
type EventHandler,
type AgentEvent,
@@ -109,6 +110,22 @@ const DEFAULT_OPENCODE_BASE_URL = "http://localhost:4096";
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_RETRY_DELAY = 1000;
+function parseOpenCodeMcpToolId(toolId: string): { server: string; tool: string } | null {
+ const match = toolId.match(/^mcp__(.+?)__(.+)$/);
+ if (!match) return null;
+ const server = match[1]?.trim();
+ const tool = match[2]?.trim();
+ if (!server || !tool) return null;
+ return { server, tool };
+}
+
+function mapOpenCodeMcpStatusToAuth(status: string | undefined): "Not logged in" | undefined {
+ if (status === "needs_auth") {
+ return "Not logged in";
+ }
+ return undefined;
+}
+
/**
* Options for creating an OpenCode client
*/
@@ -795,6 +812,101 @@ export class OpenCodeClient implements CodingAgentClient {
);
}
+ private async buildOpenCodeMcpSnapshot(): Promise {
+ if (!this.sdkClient) {
+ return null;
+ }
+
+ const directory = this.clientOptions.directory;
+ const [statusResult, toolIdsResult, resourcesResult] = await Promise.allSettled([
+ this.sdkClient.mcp.status({ directory }),
+ this.sdkClient.tool.ids({ directory }),
+ this.sdkClient.experimental.resource.list({ directory }),
+ ]);
+
+ let hasSuccessfulSource = false;
+ const servers: McpRuntimeSnapshot["servers"] = {};
+
+ const ensureServer = (name: string) => {
+ if (!servers[name]) {
+ servers[name] = {};
+ }
+ return servers[name]!;
+ };
+
+ if (statusResult.status === "fulfilled" && !statusResult.value.error && statusResult.value.data) {
+ hasSuccessfulSource = true;
+ const statuses = statusResult.value.data as Record;
+ for (const [serverName, status] of Object.entries(statuses)) {
+ const server = ensureServer(serverName);
+ const authStatus = mapOpenCodeMcpStatusToAuth(status.status);
+ if (authStatus) {
+ server.authStatus = authStatus;
+ }
+ }
+ }
+
+ if (toolIdsResult.status === "fulfilled" && !toolIdsResult.value.error && Array.isArray(toolIdsResult.value.data)) {
+ hasSuccessfulSource = true;
+ for (const toolId of toolIdsResult.value.data) {
+ if (typeof toolId !== "string") continue;
+ const parsed = parseOpenCodeMcpToolId(toolId);
+ if (!parsed) continue;
+ const server = ensureServer(parsed.server);
+ const toolNames = server.tools ?? [];
+ toolNames.push(toolId);
+ server.tools = toolNames;
+ }
+ }
+
+ if (resourcesResult.status === "fulfilled" && !resourcesResult.value.error && resourcesResult.value.data) {
+ hasSuccessfulSource = true;
+ const resourceMap = resourcesResult.value.data as Record;
+ for (const resource of Object.values(resourceMap)) {
+ if (!resource.client || !resource.name || !resource.uri) continue;
+ const server = ensureServer(resource.client);
+ const serverResources = server.resources ?? [];
+ serverResources.push({
+ name: resource.name,
+ uri: resource.uri,
+ });
+ server.resources = serverResources;
+ }
+ }
+
+ if (!hasSuccessfulSource) {
+ return null;
+ }
+
+ for (const server of Object.values(servers)) {
+ if (server.tools && server.tools.length > 0) {
+ server.tools = [...new Set(server.tools)].sort((a, b) => a.localeCompare(b));
+ }
+
+ if (server.resources && server.resources.length > 0) {
+ const deduped: typeof server.resources = [];
+ const seen = new Set();
+ for (const resource of server.resources) {
+ const key = `${resource.name}\u0000${resource.uri}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ deduped.push(resource);
+ }
+ server.resources = deduped.sort((a, b) => {
+ const byName = a.name.localeCompare(b.name);
+ if (byName !== 0) return byName;
+ return a.uri.localeCompare(b.uri);
+ });
+ }
+ }
+
+ return { servers };
+ }
+
private async wrapSession(sessionId: string, config: SessionConfig): Promise {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const client = this;
@@ -1211,6 +1323,13 @@ export class OpenCodeClient implements CodingAgentClient {
return sessionState.systemToolsBaseline;
},
+ getMcpSnapshot: async (): Promise => {
+ if (sessionState.isClosed) {
+ return null;
+ }
+ return client.buildOpenCodeMcpSnapshot();
+ },
+
destroy: async (): Promise => {
if (sessionState.isClosed) {
return;
diff --git a/src/sdk/types.ts b/src/sdk/types.ts
index cf33191b..978922b1 100644
--- a/src/sdk/types.ts
+++ b/src/sdk/types.ts
@@ -5,7 +5,7 @@
* coding agent SDKs (Claude, OpenCode, Copilot) through a common abstraction.
*/
-import type { AgentType } from "../utils/telemetry/types.ts";
+import type { AgentType } from "../telemetry/types.ts";
/**
* Permission modes for tool execution approval
@@ -44,10 +44,44 @@ export interface McpServerConfig {
timeout?: number;
/** Whether the server is enabled (default: true) */
enabled?: boolean;
+ /** Optional reason shown when the server is disabled */
+ disabledReason?: string;
/** Restrict available tools to this whitelist (default: all tools) */
tools?: string[];
}
+/** Authentication status for an MCP server. */
+export type McpAuthStatus = "Unsupported" | "Not logged in" | "Bearer token" | "OAuth";
+
+/** MCP resource metadata from runtime server introspection. */
+export interface McpRuntimeResource {
+ name: string;
+ title?: string;
+ uri: string;
+}
+
+/** MCP resource template metadata from runtime server introspection. */
+export interface McpRuntimeResourceTemplate {
+ name: string;
+ title?: string;
+ uriTemplate: string;
+}
+
+/** Runtime MCP details for a specific server. */
+export interface McpRuntimeServerSnapshot {
+ authStatus?: McpAuthStatus;
+ tools?: string[];
+ resources?: McpRuntimeResource[];
+ resourceTemplates?: McpRuntimeResourceTemplate[];
+ httpHeaders?: Record;
+ envHttpHeaders?: Record;
+}
+
+/** Runtime MCP details keyed by server name. */
+export interface McpRuntimeSnapshot {
+ servers: Record;
+}
+
/**
* OpenCode agent modes for different use cases
* - build: Default mode with full tool access for development
@@ -220,6 +254,12 @@ export interface Session {
*/
getSystemToolsTokens(): number;
+ /**
+ * Optional runtime MCP server snapshot.
+ * Implementations may omit this and callers should gracefully fall back.
+ */
+ getMcpSnapshot?(): Promise;
+
/**
* Destroy the session and release resources.
* Should be called when the session is no longer needed.
diff --git a/src/utils/telemetry/constants.ts b/src/telemetry/constants.ts
similarity index 100%
rename from src/utils/telemetry/constants.ts
rename to src/telemetry/constants.ts
diff --git a/src/telemetry/graph-integration.ts b/src/telemetry/graph-integration.ts
new file mode 100644
index 00000000..9bb05035
--- /dev/null
+++ b/src/telemetry/graph-integration.ts
@@ -0,0 +1,178 @@
+/**
+ * Graph telemetry compatibility integration.
+ *
+ * This module provides the workflow telemetry tracker interface consumed by
+ * the graph executor. It is intentionally fail-safe and callback-based:
+ * - No-op when disabled
+ * - Optional event callback for downstream consumers
+ * - Never throws
+ */
+
+/**
+ * Event emitted by the workflow telemetry tracker.
+ */
+export interface WorkflowTelemetryEvent {
+ eventType:
+ | "workflow_start"
+ | "workflow_node_enter"
+ | "workflow_node_exit"
+ | "workflow_error"
+ | "workflow_complete";
+ executionId: string;
+ timestamp: string;
+ workflowName?: string;
+ nodeId?: string;
+ nodeType?: string;
+ durationMs?: number;
+ success?: boolean;
+ errorMessage?: string;
+ maxSteps?: number;
+ resuming?: boolean;
+}
+
+/**
+ * Runtime options for workflow telemetry tracking.
+ */
+export interface WorkflowTelemetryConfig {
+ /**
+ * When false, tracking is disabled.
+ * Default: true
+ */
+ enabled?: boolean;
+ /**
+ * Sampling rate in [0, 1].
+ * Default: 1
+ */
+ sampleRate?: number;
+ /**
+ * Optional callback invoked for each emitted event.
+ */
+ onEvent?: (event: WorkflowTelemetryEvent) => void;
+}
+
+/**
+ * Workflow telemetry tracker contract used by CompiledGraph.
+ */
+export interface WorkflowTracker {
+ start(workflowName: string, meta?: { maxSteps?: number; resuming?: boolean }): void;
+ nodeEnter(nodeId: string, nodeType: string): void;
+ nodeExit(nodeId: string, nodeType: string, durationMs: number): void;
+ error(errorMessage: string, nodeId?: string): void;
+ complete(success: boolean, durationMs: number): void;
+}
+
+const NOOP_TRACKER: WorkflowTracker = {
+ start: () => {},
+ nodeEnter: () => {},
+ nodeExit: () => {},
+ error: () => {},
+ complete: () => {},
+};
+
+function clampSampleRate(value: number | undefined): number {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return 1;
+ }
+ if (value <= 0) {
+ return 0;
+ }
+ if (value >= 1) {
+ return 1;
+ }
+ return value;
+}
+
+function shouldSample(sampleRate: number): boolean {
+ if (sampleRate <= 0) {
+ return false;
+ }
+ if (sampleRate >= 1) {
+ return true;
+ }
+ return Math.random() < sampleRate;
+}
+
+function safeEmit(
+ onEvent: ((event: WorkflowTelemetryEvent) => void) | undefined,
+ event: WorkflowTelemetryEvent
+): void {
+ if (!onEvent) {
+ return;
+ }
+ try {
+ onEvent(event);
+ } catch {
+ // Fail-safe: telemetry must never affect workflow execution.
+ }
+}
+
+/**
+ * Create a workflow tracker instance for one execution.
+ *
+ * @param executionId - Unique execution identifier
+ * @param config - Optional telemetry config
+ * @returns A tracker that emits callback events or no-ops
+ */
+export function trackWorkflowExecution(
+ executionId: string,
+ config?: WorkflowTelemetryConfig
+): WorkflowTracker {
+ const enabled = config?.enabled !== false;
+ const sampleRate = clampSampleRate(config?.sampleRate);
+
+ if (!enabled || !shouldSample(sampleRate)) {
+ return NOOP_TRACKER;
+ }
+
+ const onEvent = config?.onEvent;
+
+ return {
+ start: (workflowName, meta) => {
+ safeEmit(onEvent, {
+ eventType: "workflow_start",
+ executionId,
+ timestamp: new Date().toISOString(),
+ workflowName,
+ maxSteps: meta?.maxSteps,
+ resuming: meta?.resuming,
+ });
+ },
+ nodeEnter: (nodeId, nodeType) => {
+ safeEmit(onEvent, {
+ eventType: "workflow_node_enter",
+ executionId,
+ timestamp: new Date().toISOString(),
+ nodeId,
+ nodeType,
+ });
+ },
+ nodeExit: (nodeId, nodeType, durationMs) => {
+ safeEmit(onEvent, {
+ eventType: "workflow_node_exit",
+ executionId,
+ timestamp: new Date().toISOString(),
+ nodeId,
+ nodeType,
+ durationMs: Math.max(0, Math.floor(durationMs)),
+ });
+ },
+ error: (errorMessage, nodeId) => {
+ safeEmit(onEvent, {
+ eventType: "workflow_error",
+ executionId,
+ timestamp: new Date().toISOString(),
+ nodeId,
+ errorMessage,
+ });
+ },
+ complete: (success, durationMs) => {
+ safeEmit(onEvent, {
+ eventType: "workflow_complete",
+ executionId,
+ timestamp: new Date().toISOString(),
+ success,
+ durationMs: Math.max(0, Math.floor(durationMs)),
+ });
+ },
+ };
+}
diff --git a/src/utils/telemetry/index.ts b/src/telemetry/index.ts
similarity index 64%
rename from src/utils/telemetry/index.ts
rename to src/telemetry/index.ts
index d7b737d8..c5819a66 100644
--- a/src/utils/telemetry/index.ts
+++ b/src/telemetry/index.ts
@@ -15,6 +15,14 @@ export type {
AtomicCommandEvent,
CliCommandEvent,
AgentSessionEvent,
+ TuiSessionStartEvent,
+ TuiSessionEndEvent,
+ TuiMessageSubmitEvent,
+ TuiCommandExecutionEvent,
+ TuiToolLifecycleEvent,
+ TuiInterruptEvent,
+ TuiCommandCategory,
+ TuiCommandTrigger,
TelemetryEvent,
} from "./types";
@@ -33,11 +41,19 @@ export {
// CLI telemetry tracking
export {
trackAtomicCommand,
- trackCliInvocation,
- extractCommandsFromArgs,
getEventsFilePath,
} from "./telemetry-cli";
+// Native TUI telemetry tracking
+export {
+ createTuiTelemetrySessionTracker,
+ TuiTelemetrySessionTracker,
+ type CreateTuiTelemetrySessionOptions,
+ type TrackTuiMessageSubmitOptions,
+ type TrackTuiCommandExecutionOptions,
+ type TuiSessionSummary,
+} from "./telemetry-tui";
+
// Session telemetry tracking (for agent hooks)
export {
trackAgentSession,
@@ -60,4 +76,12 @@ export {
splitIntoBatches,
TELEMETRY_UPLOAD_CONFIG,
type UploadResult,
-} from "./telemetry-upload";
\ No newline at end of file
+} from "./telemetry-upload";
+
+// Graph workflow telemetry integration
+export {
+ trackWorkflowExecution,
+ type WorkflowTracker,
+ type WorkflowTelemetryConfig,
+ type WorkflowTelemetryEvent,
+} from "./graph-integration";
diff --git a/src/utils/telemetry/telemetry-cli.ts b/src/telemetry/telemetry-cli.ts
similarity index 50%
rename from src/utils/telemetry/telemetry-cli.ts
rename to src/telemetry/telemetry-cli.ts
index 337bab48..1a0b63b7 100644
--- a/src/utils/telemetry/telemetry-cli.ts
+++ b/src/telemetry/telemetry-cli.ts
@@ -2,7 +2,7 @@
* CLI telemetry module for tracking Atomic command usage
*
* Provides:
- * - trackAtomicCommand() for tracking init, update, uninstall, run commands
+ * - trackAtomicCommand() for tracking init, update, uninstall, chat commands
* - JSONL event buffering to telemetry-events.jsonl
* - Fail-safe, non-blocking operation (telemetry never breaks CLI)
*
@@ -14,10 +14,8 @@ import type {
AtomicCommandEvent,
AtomicCommandType,
AgentType,
- CliCommandEvent,
} from "./types";
-import { VERSION } from "../../version";
-import { ATOMIC_COMMANDS } from "./constants";
+import { VERSION } from "../version";
import { appendEvent, getEventsFilePath } from "./telemetry-file-io";
// Re-export for backward compatibility
@@ -56,48 +54,14 @@ function createBaseEvent(): BaseEventFields {
};
}
-/**
- * Extract Atomic slash commands from CLI arguments.
- * Used to identify which commands were passed to the agent.
- *
- * @param args - The CLI arguments array (e.g., ['/research-codebase', 'src/'])
- * @returns Array of unique slash commands found in args
- *
- * @example
- * extractCommandsFromArgs(['/research-codebase', 'src/'])
- * // Returns: ['/research-codebase']
- *
- * @example
- * extractCommandsFromArgs(['/commit', '/create-gh-pr'])
- * // Returns: ['/commit', '/create-gh-pr']
- */
-export function extractCommandsFromArgs(args: string[]): string[] {
- const foundCommands: string[] = [];
-
- for (const arg of args) {
- for (const cmd of ATOMIC_COMMANDS) {
- // Exact match or prefix match (command followed by space and args)
- if (arg === cmd || arg.startsWith(cmd + " ")) {
- foundCommands.push(cmd);
- break; // Only match one command per arg
- }
- }
- }
-
- // Return deduplicated array
- return [...new Set(foundCommands)];
-}
-
-// appendEvent moved to telemetry-file-io.ts to avoid duplication
-
/**
* Track an Atomic CLI command execution.
*
- * This function should be called when init, update, uninstall, or run commands
+ * This function should be called when init, update, uninstall, or chat commands
* are executed. It logs an event to the local telemetry buffer if telemetry
* is enabled.
*
- * @param command - The command being executed ('init', 'update', 'uninstall', 'run')
+ * @param command - The command being executed ('init', 'update', 'uninstall', 'chat')
* @param agentType - The agent type if applicable (null for agent-agnostic commands)
* @param success - Whether the command succeeded (defaults to true)
*
@@ -110,8 +74,8 @@ export function extractCommandsFromArgs(args: string[]): string[] {
* trackAtomicCommand('update', null, false);
*
* @example
- * // Track run command with opencode agent
- * trackAtomicCommand('run', 'opencode', true);
+ * // Track chat command with opencode agent
+ * trackAtomicCommand('chat', 'opencode', true);
*/
export function trackAtomicCommand(
command: AtomicCommandType,
@@ -136,49 +100,3 @@ export function trackAtomicCommand(
// Write to JSONL buffer
appendEvent(event, agentType);
}
-
-/**
- * Track CLI invocation with slash commands.
- *
- * This function should be called before spawning the agent process when
- * CLI args contain slash commands. It logs a CliCommandEvent to the local
- * telemetry buffer if telemetry is enabled and commands are found.
- *
- * @param agentType - The agent type being invoked ('claude', 'opencode', 'copilot')
- * @param args - The CLI arguments passed to the agent
- *
- * @example
- * // Track CLI invocation with research command
- * trackCliInvocation('claude', ['/research-codebase', 'src/']);
- *
- * @example
- * // Track CLI invocation with multiple commands
- * trackCliInvocation('claude', ['/commit', '-m', 'fix bug']);
- */
-export function trackCliInvocation(agentType: AgentType, args: string[]): void {
- // Return early (no-op) if telemetry is disabled
- if (!isTelemetryEnabledSync()) {
- return;
- }
-
- // Extract slash commands from args
- const commands = extractCommandsFromArgs(args);
-
- // Don't log events with no commands
- if (commands.length === 0) {
- return;
- }
-
- // Create the event using the factory pattern
- const baseFields = createBaseEvent();
- const event: CliCommandEvent = {
- ...baseFields,
- eventType: "cli_command",
- agentType,
- commands,
- commandCount: commands.length,
- };
-
- // Write to JSONL buffer
- appendEvent(event, agentType);
-}
\ No newline at end of file
diff --git a/src/utils/telemetry/telemetry-consent.ts b/src/telemetry/telemetry-consent.ts
similarity index 100%
rename from src/utils/telemetry/telemetry-consent.ts
rename to src/telemetry/telemetry-consent.ts
diff --git a/src/utils/telemetry/telemetry-errors.ts b/src/telemetry/telemetry-errors.ts
similarity index 100%
rename from src/utils/telemetry/telemetry-errors.ts
rename to src/telemetry/telemetry-errors.ts
diff --git a/src/utils/telemetry/telemetry-file-io.ts b/src/telemetry/telemetry-file-io.ts
similarity index 97%
rename from src/utils/telemetry/telemetry-file-io.ts
rename to src/telemetry/telemetry-file-io.ts
index 513102ab..70611a5e 100644
--- a/src/utils/telemetry/telemetry-file-io.ts
+++ b/src/telemetry/telemetry-file-io.ts
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, appendFileSync } from "fs";
import { join } from "path";
-import { getBinaryDataDir } from "../config-path";
+import { getBinaryDataDir } from "../utils/config-path";
import type { TelemetryEvent, AgentType } from "./types";
/**
@@ -50,4 +50,4 @@ export function appendEvent(event: TelemetryEvent, agentType?: AgentType | null)
} catch {
// Fail silently - telemetry should never break the application
}
-}
\ No newline at end of file
+}
diff --git a/src/utils/telemetry/telemetry-session.ts b/src/telemetry/telemetry-session.ts
similarity index 99%
rename from src/utils/telemetry/telemetry-session.ts
rename to src/telemetry/telemetry-session.ts
index 07625bc4..433ab738 100644
--- a/src/utils/telemetry/telemetry-session.ts
+++ b/src/telemetry/telemetry-session.ts
@@ -11,7 +11,7 @@
import { isTelemetryEnabledSync, getOrCreateTelemetryState } from "./telemetry";
import type { AgentSessionEvent, AgentType } from "./types";
-import { VERSION } from "../../version";
+import { VERSION } from "../version";
import { ATOMIC_COMMANDS } from "./constants";
import { appendEvent } from "./telemetry-file-io";
@@ -200,4 +200,4 @@ export function trackAgentSession(
// Create and write the event
const event = createSessionEvent(agentType, commands);
appendEvent(event, agentType);
-}
\ No newline at end of file
+}
diff --git a/src/telemetry/telemetry-tui.ts b/src/telemetry/telemetry-tui.ts
new file mode 100644
index 00000000..cef79f70
--- /dev/null
+++ b/src/telemetry/telemetry-tui.ts
@@ -0,0 +1,240 @@
+/**
+ * Native TUI telemetry tracking.
+ *
+ * Tracks the actual chat UI lifecycle and interactions directly from the TUI:
+ * - Session start/end
+ * - Message submissions
+ * - Slash command execution results
+ * - Tool lifecycle
+ * - User interrupts
+ */
+
+import { VERSION } from "../version";
+import { appendEvent } from "./telemetry-file-io";
+import { getOrCreateTelemetryState, isTelemetryEnabledSync } from "./telemetry";
+import type {
+ AgentType,
+ TelemetryEventBase,
+ TuiCommandCategory,
+ TuiCommandExecutionEvent,
+ TuiCommandTrigger,
+ TuiInterruptEvent,
+ TuiMessageSubmitEvent,
+ TuiSessionEndEvent,
+ TuiSessionStartEvent,
+ TuiToolLifecycleEvent,
+} from "./types";
+
+export interface CreateTuiTelemetrySessionOptions {
+ agentType: AgentType;
+ workflowEnabled: boolean;
+ hasInitialPrompt: boolean;
+}
+
+export interface TrackTuiMessageSubmitOptions {
+ messageLength: number;
+ queued: boolean;
+ fromInitialPrompt: boolean;
+ hasFileMentions: boolean;
+ hasAgentMentions: boolean;
+}
+
+export interface TrackTuiCommandExecutionOptions {
+ commandName: string;
+ commandCategory: TuiCommandCategory;
+ argsLength: number;
+ success: boolean;
+ trigger: TuiCommandTrigger;
+}
+
+export interface TuiSessionSummary {
+ durationMs: number;
+ messageCount: number;
+}
+
+function createCommonBaseEvent(anonymousId: string): TelemetryEventBase {
+ return {
+ anonymousId,
+ eventId: crypto.randomUUID(),
+ timestamp: new Date().toISOString(),
+ platform: process.platform,
+ atomicVersion: VERSION,
+ source: "tui",
+ };
+}
+
+/**
+ * Tracks one TUI chat session. All methods are safe no-ops when telemetry is disabled.
+ */
+export class TuiTelemetrySessionTracker {
+ private readonly enabled: boolean;
+ private readonly agentType: AgentType;
+ private readonly sessionId: string;
+ private readonly anonymousId: string | null;
+ private ended: boolean;
+ private messageSubmitCount: number;
+ private commandCount: number;
+ private toolCallCount: number;
+ private interruptCount: number;
+
+ constructor(options: CreateTuiTelemetrySessionOptions) {
+ this.agentType = options.agentType;
+ this.sessionId = crypto.randomUUID();
+ this.ended = false;
+ this.messageSubmitCount = 0;
+ this.commandCount = 0;
+ this.toolCallCount = 0;
+ this.interruptCount = 0;
+ this.enabled = isTelemetryEnabledSync();
+ this.anonymousId = this.enabled ? getOrCreateTelemetryState().anonymousId : null;
+
+ if (!this.enabled || !this.anonymousId) {
+ return;
+ }
+
+ const event: TuiSessionStartEvent = {
+ ...createCommonBaseEvent(this.anonymousId),
+ eventType: "tui_session_start",
+ source: "tui",
+ sessionId: this.sessionId,
+ agentType: this.agentType,
+ workflowEnabled: options.workflowEnabled,
+ hasInitialPrompt: options.hasInitialPrompt,
+ };
+
+ appendEvent(event, this.agentType);
+ }
+
+ trackMessageSubmit(options: TrackTuiMessageSubmitOptions): void {
+ if (!this.enabled || !this.anonymousId || this.ended) {
+ return;
+ }
+
+ this.messageSubmitCount++;
+
+ const event: TuiMessageSubmitEvent = {
+ ...createCommonBaseEvent(this.anonymousId),
+ eventType: "tui_message_submit",
+ source: "tui",
+ sessionId: this.sessionId,
+ agentType: this.agentType,
+ messageLength: options.messageLength,
+ queued: options.queued,
+ fromInitialPrompt: options.fromInitialPrompt,
+ hasFileMentions: options.hasFileMentions,
+ hasAgentMentions: options.hasAgentMentions,
+ };
+
+ appendEvent(event, this.agentType);
+ }
+
+ trackCommandExecution(options: TrackTuiCommandExecutionOptions): void {
+ if (!this.enabled || !this.anonymousId || this.ended) {
+ return;
+ }
+
+ this.commandCount++;
+
+ const event: TuiCommandExecutionEvent = {
+ ...createCommonBaseEvent(this.anonymousId),
+ eventType: "tui_command_execution",
+ source: "tui",
+ sessionId: this.sessionId,
+ agentType: this.agentType,
+ commandName: options.commandName,
+ commandCategory: options.commandCategory,
+ argsLength: options.argsLength,
+ success: options.success,
+ trigger: options.trigger,
+ };
+
+ appendEvent(event, this.agentType);
+ }
+
+ trackToolStart(toolName: string): void {
+ if (!this.enabled || !this.anonymousId || this.ended) {
+ return;
+ }
+
+ this.toolCallCount++;
+
+ const event: TuiToolLifecycleEvent = {
+ ...createCommonBaseEvent(this.anonymousId),
+ eventType: "tui_tool_lifecycle",
+ source: "tui",
+ sessionId: this.sessionId,
+ agentType: this.agentType,
+ toolName,
+ phase: "start",
+ };
+
+ appendEvent(event, this.agentType);
+ }
+
+ trackToolComplete(toolName: string, success: boolean): void {
+ if (!this.enabled || !this.anonymousId || this.ended) {
+ return;
+ }
+
+ const event: TuiToolLifecycleEvent = {
+ ...createCommonBaseEvent(this.anonymousId),
+ eventType: "tui_tool_lifecycle",
+ source: "tui",
+ sessionId: this.sessionId,
+ agentType: this.agentType,
+ toolName,
+ phase: "complete",
+ success,
+ };
+
+ appendEvent(event, this.agentType);
+ }
+
+ trackInterrupt(sourceType: "ui" | "signal"): void {
+ if (!this.enabled || !this.anonymousId || this.ended) {
+ return;
+ }
+
+ this.interruptCount++;
+
+ const event: TuiInterruptEvent = {
+ ...createCommonBaseEvent(this.anonymousId),
+ eventType: "tui_interrupt",
+ source: "tui",
+ sessionId: this.sessionId,
+ agentType: this.agentType,
+ sourceType,
+ };
+
+ appendEvent(event, this.agentType);
+ }
+
+ end(summary: TuiSessionSummary): void {
+ if (!this.enabled || !this.anonymousId || this.ended) {
+ return;
+ }
+
+ this.ended = true;
+
+ const event: TuiSessionEndEvent = {
+ ...createCommonBaseEvent(this.anonymousId),
+ eventType: "tui_session_end",
+ source: "tui",
+ sessionId: this.sessionId,
+ agentType: this.agentType,
+ durationMs: Math.max(0, Math.floor(summary.durationMs)),
+ messageCount: this.messageSubmitCount || Math.max(0, Math.floor(summary.messageCount)),
+ commandCount: this.commandCount,
+ toolCallCount: this.toolCallCount,
+ interruptCount: this.interruptCount,
+ };
+
+ appendEvent(event, this.agentType);
+ }
+}
+
+export function createTuiTelemetrySessionTracker(
+ options: CreateTuiTelemetrySessionOptions
+): TuiTelemetrySessionTracker {
+ return new TuiTelemetrySessionTracker(options);
+}
diff --git a/src/utils/telemetry/telemetry-upload.ts b/src/telemetry/telemetry-upload.ts
similarity index 85%
rename from src/utils/telemetry/telemetry-upload.ts
rename to src/telemetry/telemetry-upload.ts
index 0038bff0..c09209a7 100644
--- a/src/utils/telemetry/telemetry-upload.ts
+++ b/src/telemetry/telemetry-upload.ts
@@ -16,13 +16,10 @@ import { logs, SeverityNumber } from "@opentelemetry/api-logs";
import { useAzureMonitor, shutdownAzureMonitor } from "@azure/monitor-opentelemetry";
import { getEventsFilePath } from "./telemetry-cli";
import { isTelemetryEnabledSync } from "./telemetry";
-import { getBinaryDataDir } from "../config-path";
+import { getBinaryDataDir } from "../utils/config-path";
import { handleTelemetryError } from "./telemetry-errors";
import type {
TelemetryEvent,
- AtomicCommandEvent,
- CliCommandEvent,
- AgentSessionEvent,
} from "./types";
/**
@@ -238,11 +235,6 @@ function emitEventsToAppInsights(events: TelemetryEvent[]): void {
const logger = logs.getLogger("atomic-telemetry");
for (const event of events) {
- // Type-safe attribute extraction
- const atomicCommandEvent = event as AtomicCommandEvent;
- const cliOrSessionEvent = event as CliCommandEvent | AgentSessionEvent;
- const sessionEvent = event as AgentSessionEvent;
-
// Build attributes object, excluding null values for type safety
const attributes: Record = {
// Required attribute for App Insights custom event routing
@@ -256,24 +248,61 @@ function emitEventsToAppInsights(events: TelemetryEvent[]): void {
source: event.source,
};
- // Add event-specific fields if present
- if (atomicCommandEvent.command !== undefined) {
- attributes.command = atomicCommandEvent.command;
- }
- if (cliOrSessionEvent.commands !== undefined) {
- attributes.commands = cliOrSessionEvent.commands.join(",");
- }
- if (cliOrSessionEvent.commandCount !== undefined) {
- attributes.command_count = cliOrSessionEvent.commandCount;
- }
- if (event.agentType !== undefined && event.agentType !== null) {
+ if ("agentType" in event && event.agentType !== undefined && event.agentType !== null) {
attributes.agent_type = event.agentType;
}
- if (atomicCommandEvent.success !== undefined) {
- attributes.success = atomicCommandEvent.success;
+ if ("sessionId" in event && typeof event.sessionId === "string") {
+ attributes.session_id = event.sessionId;
}
- if (sessionEvent.sessionId !== undefined) {
- attributes.session_id = sessionEvent.sessionId;
+
+ switch (event.eventType) {
+ case "atomic_command":
+ attributes.command = event.command;
+ attributes.success = event.success;
+ break;
+ case "cli_command":
+ attributes.commands = event.commands.join(",");
+ attributes.command_count = event.commandCount;
+ break;
+ case "agent_session":
+ attributes.commands = event.commands.join(",");
+ attributes.command_count = event.commandCount;
+ break;
+ case "tui_session_start":
+ attributes.workflow_enabled = event.workflowEnabled;
+ attributes.has_initial_prompt = event.hasInitialPrompt;
+ break;
+ case "tui_session_end":
+ attributes.duration_ms = event.durationMs;
+ attributes.message_count = event.messageCount;
+ attributes.command_count = event.commandCount;
+ attributes.tool_call_count = event.toolCallCount;
+ attributes.interrupt_count = event.interruptCount;
+ break;
+ case "tui_message_submit":
+ attributes.message_length = event.messageLength;
+ attributes.queued = event.queued;
+ attributes.from_initial_prompt = event.fromInitialPrompt;
+ attributes.has_file_mentions = event.hasFileMentions;
+ attributes.has_agent_mentions = event.hasAgentMentions;
+ break;
+ case "tui_command_execution":
+ attributes.command_name = event.commandName;
+ attributes.command_category = event.commandCategory;
+ attributes.args_length = event.argsLength;
+ attributes.success = event.success;
+ attributes.trigger = event.trigger;
+ break;
+ case "tui_tool_lifecycle":
+ attributes.tool_name = event.toolName;
+ attributes.phase = event.phase;
+ if (event.success !== undefined) {
+ attributes.success = event.success;
+ }
+ break;
+ case "tui_interrupt":
+ attributes.interrupt_source = event.sourceType;
+ break;
}
logger.emit({
@@ -451,4 +480,4 @@ export async function handleTelemetryUpload(): Promise {
error: error instanceof Error ? error.message : "Unknown error",
};
}
-}
\ No newline at end of file
+}
diff --git a/src/utils/telemetry/telemetry.ts b/src/telemetry/telemetry.ts
similarity index 99%
rename from src/utils/telemetry/telemetry.ts
rename to src/telemetry/telemetry.ts
index e85b8455..89817427 100644
--- a/src/utils/telemetry/telemetry.ts
+++ b/src/telemetry/telemetry.ts
@@ -12,7 +12,7 @@
import { join } from "path";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
-import { getBinaryDataDir } from "../config-path";
+import { getBinaryDataDir } from "../utils/config-path";
import type { TelemetryState } from "./types";
import { handleTelemetryError } from "./telemetry-errors";
@@ -268,4 +268,4 @@ export function setTelemetryEnabled(enabled: boolean): void {
}
writeTelemetryState(state);
-}
\ No newline at end of file
+}
diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts
new file mode 100644
index 00000000..e79e8a7d
--- /dev/null
+++ b/src/telemetry/types.ts
@@ -0,0 +1,199 @@
+/**
+ * Telemetry types for anonymous usage tracking
+ *
+ * Schema follows the spec in Section 5.1 of the telemetry implementation document.
+ */
+
+/**
+ * Persistent telemetry state stored in telemetry.json
+ */
+export interface TelemetryState {
+ /** Master toggle for telemetry collection */
+ enabled: boolean;
+ /** Has user explicitly consented to telemetry? */
+ consentGiven: boolean;
+ /** Anonymous UUID v4 for session correlation */
+ anonymousId: string;
+ /** ISO 8601 timestamp when state was first created */
+ createdAt: string;
+ /** ISO 8601 timestamp of last ID rotation */
+ rotatedAt: string;
+}
+
+/**
+ * Atomic CLI command types that are tracked
+ * Reference: Spec Section 5.3.1
+ */
+export type AtomicCommandType = "init" | "update" | "uninstall" | "run" | "chat";
+
+/**
+ * Agent types supported by Atomic
+ */
+export type AgentType = "claude" | "opencode" | "copilot";
+
+/**
+ * Valid telemetry event sources.
+ */
+export type TelemetryEventSource = "cli" | "session_hook" | "tui";
+
+/**
+ * Base event fields shared by all telemetry events.
+ */
+export interface TelemetryEventBase {
+ /** Anonymous UUID v4 for user correlation, rotated monthly */
+ anonymousId: string;
+ /** Unique UUID v4 for this specific event */
+ eventId: string;
+ /** ISO 8601 timestamp when event occurred */
+ timestamp: string;
+ /** Operating system platform */
+ platform: NodeJS.Platform;
+ /** Atomic CLI version */
+ atomicVersion: string;
+ /** Source of the event */
+ source: TelemetryEventSource;
+}
+
+export interface AtomicCommandEvent extends TelemetryEventBase {
+ /** Event type discriminator */
+ eventType: "atomic_command";
+ /** The Atomic CLI command that was executed */
+ command: AtomicCommandType;
+ /** The agent type selected (null for agent-agnostic commands) */
+ agentType: AgentType | null;
+ /** Whether the command succeeded */
+ success: boolean;
+ /** Source of the event (always 'cli' for CLI commands) */
+ source: "cli";
+}
+
+export interface CliCommandEvent extends TelemetryEventBase {
+ /** Event type discriminator */
+ eventType: "cli_command";
+ /** The agent type being invoked */
+ agentType: AgentType;
+ /** Array of slash commands found in CLI args */
+ commands: string[];
+ /** Number of commands (for quick aggregation) */
+ commandCount: number;
+ /** Source of the event (always 'cli' for CLI commands) */
+ source: "cli";
+}
+
+export interface AgentSessionEvent extends TelemetryEventBase {
+ /** Unique UUID v4 for this specific session (same as eventId for session events) */
+ sessionId: string;
+ /** Event type discriminator */
+ eventType: "agent_session";
+ /** The agent type that was running */
+ agentType: AgentType;
+ /** Array of Atomic slash commands used during the session */
+ commands: string[];
+ /** Number of commands (for quick aggregation) */
+ commandCount: number;
+ /** Source of the event (always 'session_hook' for session events) */
+ source: "session_hook";
+}
+
+/**
+ * Trigger source for a TUI command invocation.
+ */
+export type TuiCommandTrigger = "input" | "autocomplete" | "initial_prompt" | "mention";
+
+/**
+ * Command categories used by the TUI command registry.
+ * Kept local to telemetry to avoid coupling telemetry to UI modules.
+ */
+export type TuiCommandCategory = "builtin" | "workflow" | "skill" | "agent" | "custom" | "unknown";
+
+/**
+ * Event logged when a TUI chat session starts.
+ */
+export interface TuiSessionStartEvent extends TelemetryEventBase {
+ eventType: "tui_session_start";
+ source: "tui";
+ sessionId: string;
+ agentType: AgentType;
+ workflowEnabled: boolean;
+ hasInitialPrompt: boolean;
+}
+
+/**
+ * Event logged when a TUI chat session ends.
+ */
+export interface TuiSessionEndEvent extends TelemetryEventBase {
+ eventType: "tui_session_end";
+ source: "tui";
+ sessionId: string;
+ agentType: AgentType;
+ durationMs: number;
+ messageCount: number;
+ commandCount: number;
+ toolCallCount: number;
+ interruptCount: number;
+}
+
+/**
+ * Event logged when a user submits a message through the TUI.
+ */
+export interface TuiMessageSubmitEvent extends TelemetryEventBase {
+ eventType: "tui_message_submit";
+ source: "tui";
+ sessionId: string;
+ agentType: AgentType;
+ messageLength: number;
+ queued: boolean;
+ fromInitialPrompt: boolean;
+ hasFileMentions: boolean;
+ hasAgentMentions: boolean;
+}
+
+/**
+ * Event logged when a slash command executes in the TUI.
+ */
+export interface TuiCommandExecutionEvent extends TelemetryEventBase {
+ eventType: "tui_command_execution";
+ source: "tui";
+ sessionId: string;
+ agentType: AgentType;
+ commandName: string;
+ commandCategory: TuiCommandCategory;
+ argsLength: number;
+ success: boolean;
+ trigger: TuiCommandTrigger;
+}
+
+/**
+ * Event logged for tool lifecycle events in the TUI.
+ */
+export interface TuiToolLifecycleEvent extends TelemetryEventBase {
+ eventType: "tui_tool_lifecycle";
+ source: "tui";
+ sessionId: string;
+ agentType: AgentType;
+ toolName: string;
+ phase: "start" | "complete";
+ success?: boolean;
+}
+
+/**
+ * Event logged when a user interrupts a TUI stream.
+ */
+export interface TuiInterruptEvent extends TelemetryEventBase {
+ eventType: "tui_interrupt";
+ source: "tui";
+ sessionId: string;
+ agentType: AgentType;
+ sourceType: "ui" | "signal";
+}
+
+export type TelemetryEvent =
+ | AtomicCommandEvent
+ | CliCommandEvent
+ | AgentSessionEvent
+ | TuiSessionStartEvent
+ | TuiSessionEndEvent
+ | TuiMessageSubmitEvent
+ | TuiCommandExecutionEvent
+ | TuiToolLifecycleEvent
+ | TuiInterruptEvent;
diff --git a/src/ui/chat.tsx b/src/ui/chat.tsx
index 779b6b10..ba412d3f 100644
--- a/src/ui/chat.tsx
+++ b/src/ui/chat.tsx
@@ -72,9 +72,16 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, dirname, basename } from "node:path";
import type { AskUserQuestionEventData } from "../graph/index.ts";
import type { AgentType, ModelOperations } from "../models";
+import type { McpServerConfig } from "../sdk/types.ts";
import { saveModelPreference, saveReasoningEffortPreference, clearReasoningEffortPreference } from "../utils/settings.ts";
import { formatDuration } from "./utils/format.ts";
import { getRandomVerb, getRandomCompletionVerb } from "./constants/index.ts";
+import type { McpServerToggleMap, McpSnapshotView } from "./utils/mcp-output.ts";
+import {
+ getHitlResponseRecord,
+ normalizeHitlAnswer,
+ type HitlResponseRecord,
+} from "./utils/hitl-response.ts";
// ============================================================================
// @ MENTION HELPERS
@@ -420,6 +427,8 @@ export interface MessageToolCall {
status: ToolExecutionStatus;
/** Content offset at the time tool call started (for inline rendering) */
contentOffsetAtStart?: number;
+ /** Structured HITL response data preserved across late tool.complete events */
+ hitlResponse?: HitlResponseRecord;
}
export interface MessageSkillLoad {
@@ -471,8 +480,8 @@ export interface ChatMessage {
agentsContentOffset?: number;
/** Content offset when task list first appeared (for chronological positioning) */
tasksContentOffset?: number;
- /** MCP server list for rendering via McpServerListIndicator */
- mcpServers?: import("../sdk/types.ts").McpServerConfig[];
+ /** MCP snapshot for rendering Codex-style /mcp output */
+ mcpSnapshot?: McpSnapshotView;
contextInfo?: import("./commands/registry.ts").ContextDisplayInfo;
/** Output tokens used in this message (baked on completion) */
outputTokens?: number;
@@ -539,6 +548,33 @@ export type OnInterrupt = () => void;
*/
export type OnAskUserQuestion = (eventData: AskUserQuestionEventData) => void;
+/**
+ * Trigger source for a command execution from the TUI.
+ */
+export type CommandExecutionTrigger = "input" | "autocomplete" | "initial_prompt" | "mention";
+
+/**
+ * Telemetry payload for command execution.
+ */
+export interface CommandExecutionTelemetry {
+ commandName: string;
+ commandCategory: CommandCategory | "unknown";
+ argsLength: number;
+ success: boolean;
+ trigger: CommandExecutionTrigger;
+}
+
+/**
+ * Telemetry payload for user message submissions.
+ */
+export interface MessageSubmitTelemetry {
+ messageLength: number;
+ queued: boolean;
+ fromInitialPrompt: boolean;
+ hasFileMentions: boolean;
+ hasAgentMentions: boolean;
+}
+
/**
* Props for the ChatApp component.
*/
@@ -638,10 +674,16 @@ export interface ChatAppProps {
initialPrompt?: string;
/** Callback when the active model changes (via /model command or model selector) */
onModelChange?: (model: string) => void;
+ /** Callback to update MCP servers used by the next SDK session */
+ onSessionMcpServersChange?: (servers: McpServerConfig[]) => void;
/** Raw model ID from session config, used to seed currentModelRef for accurate /context display */
initialModelId?: string;
/** Get system tools tokens from the client (pre-session fallback) */
getClientSystemToolsTokens?: () => number | null;
+ /** Callback for slash command telemetry events */
+ onCommandExecutionTelemetry?: (event: CommandExecutionTelemetry) => void;
+ /** Callback for user message submission telemetry events */
+ onMessageSubmitTelemetry?: (event: MessageSubmitTelemetry) => void;
}
/**
@@ -1168,10 +1210,7 @@ function CompletedQuestionDisplay({ toolCall }: { toolCall: MessageToolCall }):
|| questions?.[0]?.question
|| "";
- // Extract user's answer from tool output
- const outputData = toolCall.output as { answer?: string | null; cancelled?: boolean } | undefined;
- const cancelled = outputData?.cancelled ?? false;
- const answerText = outputData?.answer ?? null;
+ const hitlResponse = getHitlResponseRecord(toolCall);
return (
@@ -1192,13 +1231,12 @@ function CompletedQuestionDisplay({ toolCall }: { toolCall: MessageToolCall }):
) : null}
{/* User's answer */}
- {cancelled ? (
-
- {PROMPT.cursor} User declined to answer question. Use your best judgement.
-
- ) : answerText ? (
-
- {PROMPT.cursor} {answerText}
+ {hitlResponse ? (
+
+ {PROMPT.cursor} {hitlResponse.displayText}
) : null}
@@ -1494,10 +1532,10 @@ export function MessageBubble({ message, isLast, syntaxStyle, hideAskUserQuestio
/>
))}
- {/* MCP server list indicator */}
- {message.mcpServers && (
+ {/* MCP snapshot indicator */}
+ {message.mcpSnapshot && (
-
+
)}
{message.contextInfo && (
@@ -1679,8 +1717,11 @@ export function ChatApp({
createSubagentSession,
initialPrompt,
onModelChange,
+ onSessionMcpServersChange,
initialModelId,
getClientSystemToolsTokens,
+ onCommandExecutionTelemetry,
+ onMessageSubmitTelemetry,
}: ChatAppProps): React.ReactNode {
// title and suggestion are deprecated, kept for backwards compatibility
void _title;
@@ -1749,6 +1790,7 @@ export function ChatApp({
const [currentModelId, setCurrentModelId] = useState(undefined);
// Store the display name separately to match what's shown in the selector dropdown
const [currentModelDisplayName, setCurrentModelDisplayName] = useState(undefined);
+ const [mcpServerToggles, setMcpServerToggles] = useState({});
// Compute display model name reactively
// Uses stored display name when available, falls back to initial model prop
@@ -1961,6 +2003,10 @@ export function ChatApp({
setWorkflowState((prev) => ({ ...prev, ...updates }));
}, []);
+ const emitMessageSubmitTelemetry = useCallback((event: MessageSubmitTelemetry) => {
+ onMessageSubmitTelemetry?.(event);
+ }, [onMessageSubmitTelemetry]);
+
/**
* Check if a tool spawns sub-agents (for offset capture).
*/
@@ -2094,10 +2140,31 @@ export function ChatApp({
const updatedInput = (input && Object.keys(tc.input).length === 0)
? input
: tc.input;
+ const isHitlTool = tc.toolName === "AskUserQuestion"
+ || tc.toolName === "question"
+ || tc.toolName === "ask_user";
+ let mergedOutput = output !== undefined ? output : tc.output;
+
+ // Preserve the canonical HITL answer text if tool.complete arrives later.
+ if (isHitlTool && tc.hitlResponse) {
+ const outputObject = (
+ mergedOutput !== null
+ && typeof mergedOutput === "object"
+ )
+ ? mergedOutput as Record
+ : {};
+ mergedOutput = {
+ ...outputObject,
+ answer: tc.hitlResponse.answerText,
+ cancelled: tc.hitlResponse.cancelled,
+ responseMode: tc.hitlResponse.responseMode,
+ displayText: tc.hitlResponse.displayText,
+ };
+ }
return {
...tc,
input: updatedInput,
- output: output !== undefined ? output : tc.output,
+ output: mergedOutput,
status: success ? "completed" as const : "error" as const,
};
}
@@ -2568,6 +2635,8 @@ export function ChatApp({
* - Otherwise, sends the answer through session.send() for standalone agent mode
*/
const handleQuestionAnswer = useCallback((answer: QuestionAnswer) => {
+ const normalizedHitl = normalizeHitlAnswer(answer);
+
// Clear active question first
setActiveQuestion(null);
@@ -2618,12 +2687,6 @@ export function ChatApp({
activeHitlToolCallIdRef.current = null;
answerStoredOnToolCall = true;
- const answerText = answer.cancelled
- ? null
- : Array.isArray(answer.selected)
- ? answer.selected.join(", ")
- : answer.selected;
-
setMessages((prev) =>
prev.map((msg) => {
if (!msg.toolCalls?.some(tc => tc.id === hitlToolId)) return msg;
@@ -2633,7 +2696,16 @@ export function ChatApp({
tc.id === hitlToolId
? {
...tc,
- output: { answer: answerText, cancelled: answer.cancelled },
+ output: {
+ ...(tc.output && typeof tc.output === "object"
+ ? tc.output as Record
+ : {}),
+ answer: normalizedHitl.answerText,
+ cancelled: normalizedHitl.cancelled,
+ responseMode: normalizedHitl.responseMode,
+ displayText: normalizedHitl.displayText,
+ },
+ hitlResponse: normalizedHitl,
contentOffsetAtStart: msg.content.length,
}
: tc
@@ -2644,10 +2716,12 @@ export function ChatApp({
}
// Fallback for askUserNode questions (no tool call) — insert as user message
- if (!answer.cancelled && !answerStoredOnToolCall) {
- const answerText = Array.isArray(answer.selected)
- ? answer.selected.join(", ")
- : answer.selected;
+ if (!answerStoredOnToolCall) {
+ const answerText = answer.cancelled
+ ? normalizedHitl.displayText
+ : Array.isArray(answer.selected)
+ ? answer.selected.join(", ")
+ : answer.selected;
setMessages((prev) => {
const streamingIdx = prev.findIndex(m => m.streaming);
const answerMsg = createMessage("user", answerText);
@@ -2726,7 +2800,7 @@ export function ChatApp({
const sendMessageRef = useRef<((content: string, options?: { skipUserMessage?: boolean }) => void) | null>(null);
// Ref for executeCommand to allow deferred message handling to spawn agents
- const executeCommandRef = useRef<((commandName: string, args: string) => Promise) | null>(null);
+ const executeCommandRef = useRef<((commandName: string, args: string, trigger?: CommandExecutionTrigger) => Promise) | null>(null);
const dispatchQueuedMessageRef = useRef<(queuedMessage: QueuedMessage) => void>(() => {});
const dispatchQueuedMessage = useCallback((queuedMessage: QueuedMessage) => {
@@ -2750,7 +2824,7 @@ export function ChatApp({
setMessages((prev: ChatMessage[]) => [...prev, assistantMsg]);
for (const mention of atMentions) {
- void executeCommandRef.current(mention.agentName, mention.args);
+ void executeCommandRef.current(mention.agentName, mention.args, "mention");
}
return;
}
@@ -2963,7 +3037,8 @@ export function ChatApp({
*/
const executeCommand = useCallback(async (
commandName: string,
- args: string
+ args: string,
+ trigger: CommandExecutionTrigger = "input"
): Promise => {
// Clear stale todo items from previous commands
setTodoItems([]);
@@ -2974,6 +3049,13 @@ export function ChatApp({
if (!command) {
// Command not found - show error message
addMessage("system", `Unknown command: /${commandName}. Type /help for available commands.`);
+ onCommandExecutionTelemetry?.({
+ commandName,
+ commandCategory: "unknown",
+ argsLength: args.length,
+ success: false,
+ trigger,
+ });
return false;
}
@@ -3258,6 +3340,16 @@ export function ChatApp({
}
: undefined,
getClientSystemToolsTokens,
+ getMcpServerToggles: () => mcpServerToggles,
+ setMcpServerEnabled: (name: string, enabled: boolean) => {
+ setMcpServerToggles((previous) => ({
+ ...previous,
+ [name]: enabled,
+ }));
+ },
+ setSessionMcpServers: (servers: McpServerConfig[]) => {
+ onSessionMcpServersChange?.(servers);
+ },
};
// Delayed spinner: show loading indicator if command takes >250ms
@@ -3380,20 +3472,20 @@ export function ChatApp({
});
}
- // Track MCP server list in message for UI indicator
- if (result.mcpServers) {
- const mcpServers = result.mcpServers;
+ // Track MCP snapshot in message for UI indicator
+ if (result.mcpSnapshot) {
+ const mcpSnapshot = result.mcpSnapshot;
setMessages((prev) => {
const lastMsg = prev[prev.length - 1];
if (lastMsg && lastMsg.role === "assistant") {
return [
...prev.slice(0, -1),
- { ...lastMsg, mcpServers },
+ { ...lastMsg, mcpSnapshot },
];
}
- // No assistant message yet — create one with MCP servers
+ // No assistant message yet — create one with MCP snapshot
const msg = createMessage("assistant", "");
- msg.mcpServers = mcpServers;
+ msg.mcpSnapshot = mcpSnapshot;
return [...prev, msg];
});
}
@@ -3446,12 +3538,15 @@ export function ChatApp({
clearTimeout(commandSpinnerTimer);
if (commandSpinnerShown && commandSpinnerMsgId) {
const msgId = commandSpinnerMsgId;
- if (result.message && !result.clearMessages) {
- // Replace spinner message with result content
+ const hasStructuredPayload = Boolean(
+ result.mcpSnapshot || result.contextInfo || result.skillLoaded
+ );
+ if ((result.message || hasStructuredPayload) && !result.clearMessages) {
+ // Preserve the spinner placeholder when command data is attached to it.
setMessages((prev) =>
prev.map((msg) =>
msg.id === msgId
- ? { ...msg, content: result.message!, streaming: false }
+ ? { ...msg, content: result.message ?? msg.content, streaming: false }
: msg
)
);
@@ -3464,6 +3559,14 @@ export function ChatApp({
streamingStartRef.current = null;
}
+ onCommandExecutionTelemetry?.({
+ commandName,
+ commandCategory: command.category,
+ argsLength: args.length,
+ success: result.success,
+ trigger,
+ });
+
return result.success;
} catch (error) {
// Clean up delayed spinner on error
@@ -3478,9 +3581,16 @@ export function ChatApp({
// Handle execution error (as assistant message, not system)
const errorMessage = error instanceof Error ? error.message : "Unknown error";
addMessage("assistant", `Error executing /${commandName}: ${errorMessage}`);
+ onCommandExecutionTelemetry?.({
+ commandName,
+ commandCategory: command.category,
+ argsLength: args.length,
+ success: false,
+ trigger,
+ });
return false;
}
- }, [isStreaming, messages.length, workflowState, addMessage, updateWorkflowState, toggleTheme, setTheme, onSendMessage, onStreamMessage, getSession, model, onModelChange]);
+ }, [isStreaming, messages.length, workflowState, addMessage, updateWorkflowState, toggleTheme, setTheme, onSendMessage, onStreamMessage, getSession, model, onModelChange, onSessionMcpServersChange, onCommandExecutionTelemetry, mcpServerToggles]);
/**
* Handle autocomplete selection (Tab for complete, Enter for execute).
@@ -3558,7 +3668,7 @@ export function ChatApp({
argumentHint: "",
});
addMessage("user", remaining ? `@${command.name} ${remaining}` : `@${command.name}`);
- void executeCommand(command.name, remaining);
+ void executeCommand(command.name, remaining, "mention");
}
}
} else {
@@ -3579,7 +3689,7 @@ export function ChatApp({
textarea.insertText(`/${command.name} `);
} else {
addMessage("user", `/${command.name}`);
- void executeCommand(command.name, "");
+ void executeCommand(command.name, "", "autocomplete");
}
}
}, [updateWorkflowState, executeCommand, addMessage, workflowState.autocompleteMode, workflowState.mentionStartOffset, workflowState.autocompleteInput]);
@@ -3866,6 +3976,15 @@ export function ChatApp({
const textarea = textareaRef.current;
const value = textarea?.plainText?.trim() ?? "";
if (value) {
+ const hasAgentMentions = parseAtMentions(value).length > 0;
+ const hasAnyMentionToken = /@([\w./_-]+)/.test(value);
+ emitMessageSubmitTelemetry({
+ messageLength: value.length,
+ queued: true,
+ fromInitialPrompt: false,
+ hasFileMentions: hasAnyMentionToken && !hasAgentMentions,
+ hasAgentMentions,
+ });
messageQueue.enqueue(value);
// Clear textarea
if (textarea) {
@@ -4351,7 +4470,7 @@ export function ChatApp({
? `@${selectedCommand.name} ${remaining}`
: `@${selectedCommand.name}`;
addMessage("user", displayText);
- void executeCommand(selectedCommand.name, remaining);
+ void executeCommand(selectedCommand.name, remaining, "mention");
} else {
// File @ mention: insert completed mention into text
const replacement = `@${selectedCommand.name} `;
@@ -4377,7 +4496,7 @@ export function ChatApp({
autocompleteMode: "command",
});
addMessage("user", `/${selectedCommand.name}`);
- void executeCommand(selectedCommand.name, "");
+ void executeCommand(selectedCommand.name, "", "autocomplete");
}
}
// Prevent textarea's built-in "return → submit" from firing
@@ -4407,7 +4526,7 @@ export function ChatApp({
syncInputScrollbar();
}, 0);
},
- [onExit, onInterrupt, isStreaming, interruptCount, handleCopy, workflowState.showAutocomplete, workflowState.selectedSuggestionIndex, workflowState.autocompleteInput, workflowState.autocompleteMode, autocompleteSuggestions, updateWorkflowState, handleInputChange, syncInputScrollbar, executeCommand, activeQuestion, showModelSelector, ctrlCPressed, messageQueue, setIsEditingQueue, parallelAgents, compactionSummary, addMessage, renderer, resumeSuggestion]
+ [onExit, onInterrupt, isStreaming, interruptCount, handleCopy, workflowState.showAutocomplete, workflowState.selectedSuggestionIndex, workflowState.autocompleteInput, workflowState.autocompleteMode, autocompleteSuggestions, updateWorkflowState, handleInputChange, syncInputScrollbar, executeCommand, activeQuestion, showModelSelector, ctrlCPressed, messageQueue, setIsEditingQueue, parallelAgents, compactionSummary, addMessage, renderer, resumeSuggestion, emitMessageSubmitTelemetry]
)
);
@@ -4628,7 +4747,7 @@ export function ChatApp({
const parsed = parseSlashCommand(initialPrompt);
if (parsed.isCommand) {
addMessage("user", initialPrompt);
- void executeCommand(parsed.name, parsed.args);
+ void executeCommand(parsed.name, parsed.args, "initial_prompt");
return;
}
@@ -4641,16 +4760,23 @@ export function ChatApp({
const agentCommand = globalRegistry.get(agentName);
if (agentCommand && agentCommand.category === "agent") {
addMessage("user", initialPrompt);
- void executeCommand(agentName, agentArgs);
+ void executeCommand(agentName, agentArgs, "mention");
return;
}
}
- const { message: processed } = processFileMentions(initialPrompt);
+ const { message: processed, filesRead } = processFileMentions(initialPrompt);
+ emitMessageSubmitTelemetry({
+ messageLength: initialPrompt.length,
+ queued: false,
+ fromInitialPrompt: true,
+ hasFileMentions: filesRead.length > 0,
+ hasAgentMentions: false,
+ });
sendMessage(processed);
}, 0);
}
- }, [initialPrompt, sendMessage, addMessage, executeCommand]);
+ }, [initialPrompt, sendMessage, addMessage, executeCommand, emitMessageSubmitTelemetry]);
/**
* Handle message submission from textarea.
@@ -4718,7 +4844,7 @@ export function ChatApp({
// Add the slash command to conversation history like any regular user message
addMessage("user", trimmedValue);
// Execute the slash command (allowed even during streaming)
- void executeCommand(parsed.name, parsed.args);
+ void executeCommand(parsed.name, parsed.args, "input");
return;
}
@@ -4740,10 +4866,24 @@ export function ChatApp({
// @mention invocations queue while streaming so they stay in the
// same round-robin queue UI as Ctrl+D inputs.
if (isStreamingRef.current) {
+ emitMessageSubmitTelemetry({
+ messageLength: trimmedValue.length,
+ queued: true,
+ fromInitialPrompt: false,
+ hasFileMentions: false,
+ hasAgentMentions: true,
+ });
messageQueue.enqueue(trimmedValue);
return;
}
+ emitMessageSubmitTelemetry({
+ messageLength: trimmedValue.length,
+ queued: false,
+ fromInitialPrompt: false,
+ hasFileMentions: false,
+ hasAgentMentions: true,
+ });
addMessage("user", trimmedValue);
// Create a streaming assistant message immediately so the parallel
@@ -4764,7 +4904,7 @@ export function ChatApp({
setMessages((prev) => [...prev, assistantMsg]);
for (const mention of atMentions) {
- void executeCommand(mention.agentName, mention.args);
+ void executeCommand(mention.agentName, mention.args, "mention");
}
return;
}
@@ -4787,6 +4927,13 @@ export function ChatApp({
(a) => a.status === "running" || a.status === "pending"
);
if (hasActiveSubagents) {
+ emitMessageSubmitTelemetry({
+ messageLength: trimmedValue.length,
+ queued: true,
+ fromInitialPrompt: false,
+ hasFileMentions: true,
+ hasAgentMentions: false,
+ });
messageQueue.enqueue(processedValue, {
skipUserMessage: true,
displayContent: trimmedValue,
@@ -4824,9 +4971,23 @@ export function ChatApp({
setIsStreaming(false);
setStreamingMeta(null);
onInterrupt?.();
+ emitMessageSubmitTelemetry({
+ messageLength: trimmedValue.length,
+ queued: false,
+ fromInitialPrompt: false,
+ hasFileMentions: true,
+ hasAgentMentions: false,
+ });
sendMessage(processedValue, { skipUserMessage: true });
return;
}
+ emitMessageSubmitTelemetry({
+ messageLength: trimmedValue.length,
+ queued: false,
+ fromInitialPrompt: false,
+ hasFileMentions: true,
+ hasAgentMentions: false,
+ });
sendMessage(processedValue, { skipUserMessage: true });
return;
}
@@ -4838,6 +4999,13 @@ export function ChatApp({
(a) => a.status === "running" || a.status === "pending"
);
if (hasActiveSubagents) {
+ emitMessageSubmitTelemetry({
+ messageLength: trimmedValue.length,
+ queued: true,
+ fromInitialPrompt: false,
+ hasFileMentions: false,
+ hasAgentMentions: false,
+ });
messageQueue.enqueue(processedValue);
return;
}
@@ -4876,14 +5044,28 @@ export function ChatApp({
// Abort the SDK stream (stale handleComplete is a no-op via generation guard)
onInterrupt?.();
// Send immediately — starts a new stream generation
+ emitMessageSubmitTelemetry({
+ messageLength: trimmedValue.length,
+ queued: false,
+ fromInitialPrompt: false,
+ hasFileMentions: false,
+ hasAgentMentions: false,
+ });
sendMessage(processedValue);
return;
}
// Send the message (no file mentions - normal flow)
+ emitMessageSubmitTelemetry({
+ messageLength: trimmedValue.length,
+ queued: false,
+ fromInitialPrompt: false,
+ hasFileMentions: false,
+ hasAgentMentions: false,
+ });
sendMessage(processedValue);
},
- [workflowState.showAutocomplete, workflowState.argumentHint, updateWorkflowState, addMessage, executeCommand, messageQueue, sendMessage, model, onInterrupt]
+ [workflowState.showAutocomplete, workflowState.argumentHint, updateWorkflowState, addMessage, executeCommand, messageQueue, sendMessage, model, onInterrupt, emitMessageSubmitTelemetry]
);
// Get the visible messages and hidden transcript count for UI rendering.
diff --git a/src/ui/commands/builtin-commands.ts b/src/ui/commands/builtin-commands.ts
index 989f8465..7fc48d6c 100644
--- a/src/ui/commands/builtin-commands.ts
+++ b/src/ui/commands/builtin-commands.ts
@@ -19,6 +19,11 @@ import { globalRegistry } from "./registry.ts";
import { saveModelPreference, clearReasoningEffortPreference } from "../../utils/settings.ts";
import { discoverMcpConfigs } from "../../utils/mcp-config.ts";
import { BACKGROUND_COMPACTION_THRESHOLD } from "../../graph/types.ts";
+import {
+ buildMcpSnapshotView,
+ getActiveMcpServers,
+ type McpServerToggleMap,
+} from "../utils/mcp-output.ts";
// ============================================================================
@@ -422,37 +427,64 @@ export const mcpCommand: CommandDefinition = {
description: "View and toggle MCP servers",
category: "builtin",
argumentHint: "[enable|disable ]",
- execute: (args: string, _context: CommandContext): CommandResult => {
- const servers = discoverMcpConfigs();
- const trimmed = args.trim().toLowerCase();
+ execute: async (args: string, context: CommandContext): Promise => {
+ const servers = discoverMcpConfigs(undefined, { includeDisabled: true });
+ const toggles = context.getMcpServerToggles?.() ?? {};
+ const trimmed = args.trim();
+ const normalized = trimmed.toLowerCase();
+
+ let runtimeSnapshot = null;
+ if (context.session?.getMcpSnapshot) {
+ try {
+ runtimeSnapshot = await context.session.getMcpSnapshot();
+ } catch {
+ runtimeSnapshot = null;
+ }
+ }
// No args: list servers (rendered via McpServerListIndicator component)
- if (!trimmed) {
+ if (!normalized) {
return {
success: true,
- mcpServers: servers,
+ mcpSnapshot: buildMcpSnapshotView({
+ servers,
+ toggles,
+ runtimeSnapshot,
+ }),
};
}
// enable/disable subcommands
- const parts = trimmed.split(/\s+/);
+ const parts = normalized.split(/\s+/);
const subcommand = parts[0];
- const serverName = parts.slice(1).join(" ");
+ const serverName = trimmed.split(/\s+/).slice(1).join(" ");
if ((subcommand === "enable" || subcommand === "disable") && serverName) {
- const found = servers.find(s => s.name.toLowerCase() === serverName.toLowerCase());
+ const found = servers.find((server) => server.name.toLowerCase() === serverName.toLowerCase());
if (!found) {
return {
success: false,
message: `MCP server '${serverName}' not found. Run /mcp to see available servers.`,
};
}
+
+ const enabled = subcommand === "enable";
+ const nextToggles: McpServerToggleMap = {
+ ...toggles,
+ [found.name]: enabled,
+ };
+
+ context.setMcpServerEnabled?.(found.name, enabled);
+ context.setSessionMcpServers?.(getActiveMcpServers(servers, nextToggles));
+
return {
success: true,
- message: `MCP server '${found.name}' ${subcommand}d for this session.`,
- stateUpdate: {
- mcpToggle: { name: found.name, enabled: subcommand === "enable" },
- } as unknown as CommandResult["stateUpdate"],
+ message: `MCP server '${found.name}' ${enabled ? "enabled" : "disabled"} for this session. Changes apply to the next session.`,
+ mcpSnapshot: buildMcpSnapshotView({
+ servers,
+ toggles: nextToggles,
+ runtimeSnapshot,
+ }),
};
}
diff --git a/src/ui/commands/registry.ts b/src/ui/commands/registry.ts
index b5ccd06f..8eaf63cc 100644
--- a/src/ui/commands/registry.ts
+++ b/src/ui/commands/registry.ts
@@ -7,9 +7,10 @@
* Reference: Feature 1 - Create CommandRegistry class and CommandDefinition interface
*/
-import type { Session, ModelDisplayInfo } from "../../sdk/types.ts";
+import type { Session, ModelDisplayInfo, McpServerConfig } from "../../sdk/types.ts";
import type { AgentType, ModelOperations } from "../../models";
import type { TodoItem } from "../../sdk/tools/todo-write.ts";
+import type { McpServerToggleMap, McpSnapshotView } from "../utils/mcp-output.ts";
// ============================================================================
// TYPES
@@ -129,6 +130,12 @@ export interface CommandContext {
getModelDisplayInfo?: () => Promise;
/** Get system tools tokens from the client (pre-session fallback) */
getClientSystemToolsTokens?: () => number | null;
+ /** Get current session-level MCP toggles keyed by server name */
+ getMcpServerToggles?: () => McpServerToggleMap;
+ /** Set one MCP server toggle for this TUI session */
+ setMcpServerEnabled?: (name: string, enabled: boolean) => void;
+ /** Update MCP servers used when creating the next SDK session */
+ setSessionMcpServers?: (servers: McpServerConfig[]) => void;
}
/**
@@ -209,6 +216,8 @@ export interface CommandResult {
showMcpOverlay?: boolean;
/** MCP server list to display via McpServerListIndicator */
mcpServers?: import("../../sdk/types.ts").McpServerConfig[];
+ /** Rich MCP snapshot display payload for Codex-style /mcp output */
+ mcpSnapshot?: McpSnapshotView;
/** Display name for the model (used to update the header after /model command) */
modelDisplayName?: string;
/** Context usage info to display via ContextInfoDisplay */
diff --git a/src/ui/components/mcp-server-list.tsx b/src/ui/components/mcp-server-list.tsx
index c268c792..859eae70 100644
--- a/src/ui/components/mcp-server-list.tsx
+++ b/src/ui/components/mcp-server-list.tsx
@@ -1,27 +1,19 @@
/**
* McpServerListIndicator Component
*
- * Renders a colored list of discovered MCP servers with status indicators.
- * Uses theme colors for green (enabled) and red (disabled) indicators.
- *
- * Layout:
- * ● MCP Servers
- * ● deepwiki (stdio) — npx
- * ○ disabled-server (http) — https://example.com
- * Use /mcp enable or /mcp disable to toggle.
+ * Renders Codex-style /mcp output in the assistant transcript.
*/
import React from "react";
import { useTheme } from "../theme.tsx";
-import { STATUS } from "../constants/icons.ts";
-import type { McpServerConfig } from "../../sdk/types.ts";
+import type { McpSnapshotView } from "../utils/mcp-output.ts";
// ============================================================================
// TYPES
// ============================================================================
export interface McpServerListIndicatorProps {
- servers: McpServerConfig[];
+ snapshot: McpSnapshotView;
}
// ============================================================================
@@ -29,52 +21,96 @@ export interface McpServerListIndicatorProps {
// ============================================================================
export function McpServerListIndicator({
- servers,
+ snapshot,
}: McpServerListIndicatorProps): React.ReactNode {
const { theme } = useTheme();
const colors = theme.colors;
- if (servers.length === 0) {
- return (
-
-
- No MCP servers found.
-
-
- {"\n"}Add servers via .mcp.json, .copilot/mcp-config.json, .github/mcp-config.json, or .opencode/opencode.json.
-
-
- );
- }
+ const formatResources = (items: Array<{ label: string; uri: string }>): string =>
+ items.map((item) => `${item.label} (${item.uri})`).join(", ");
+
+ const formatTemplates = (items: Array<{ label: string; uriTemplate: string }>): string =>
+ items.map((item) => `${item.label} (${item.uriTemplate})`).join(", ");
return (
- MCP Servers
+ {snapshot.heading}
{""}
- {servers.map((server) => {
- const isEnabled = server.enabled !== false;
- const statusColor = isEnabled ? colors.success : colors.error;
- const statusIcon = isEnabled ? STATUS.active : STATUS.pending;
- const statusLabel = isEnabled ? "enabled" : "disabled";
- const transport = server.type ?? (server.url ? "http" : "stdio");
- const target = server.url ?? server.command ?? "—";
+
+ {!snapshot.hasConfiguredServers && (
+
+ {` • No MCP servers configured.`}
+ {` ${snapshot.docsHint}`}
+
+ )}
+
+ {snapshot.hasConfiguredServers && snapshot.noToolsAvailable && (
+
+ {` • No MCP tools available.`}
+ {""}
+
+ )}
+
+ {snapshot.servers.map((server) => {
+ if (!server.enabled) {
+ return (
+
+
+ {` • ${server.name} `}
+ (disabled)
+
+ {server.disabledReason && (
+ {` • Reason: ${server.disabledReason}`}
+ )}
+
+ );
+ }
return (
-
+
+ {` • ${server.name}`}
- {` ${statusIcon} `}
- {server.name}
- {` (${transport}) `}
- {statusLabel}
+ {` • Status: `}
+ enabled
- {` ${target}`}
+ {` • Auth: ${server.authStatus}`}
+
+ {server.transport.kind === "stdio" && (
+
+ {` • Command: ${server.transport.commandLine ?? "(none)"}`}
+ {server.transport.cwd && (
+ {` • Cwd: ${server.transport.cwd}`}
+ )}
+ {server.transport.env && server.transport.env !== "-" && (
+ {` • Env: ${server.transport.env}`}
+ )}
+
+ )}
+
+ {(server.transport.kind === "http" || server.transport.kind === "sse") && (
+
+ {` • URL: ${server.transport.url ?? "(none)"}`}
+ {server.transport.httpHeaders && server.transport.httpHeaders !== "-" && (
+ {` • HTTP headers: ${server.transport.httpHeaders}`}
+ )}
+ {server.transport.envHttpHeaders && server.transport.envHttpHeaders !== "-" && (
+ {` • Env HTTP headers: ${server.transport.envHttpHeaders}`}
+ )}
+
+ )}
+
+
+ {` • Tools: ${server.tools.length === 1 && server.tools[0] === "*" ? "(all)" : server.tools.length > 0 ? server.tools.join(", ") : "(none)"}`}
+
+
+ {` • Resources: ${server.resources.length > 0 ? formatResources(server.resources) : "(none)"}`}
+
+
+ {` • Resource templates: ${server.resourceTemplates.length > 0 ? formatTemplates(server.resourceTemplates) : "(none)"}`}
+
);
})}
- {""}
-
- Use /mcp enable {""} or /mcp disable {""} to toggle.
-
);
}
diff --git a/src/ui/components/user-question-dialog.tsx b/src/ui/components/user-question-dialog.tsx
index f58d383c..d0cf549f 100644
--- a/src/ui/components/user-question-dialog.tsx
+++ b/src/ui/components/user-question-dialog.tsx
@@ -33,6 +33,7 @@ export interface UserQuestion {
export interface QuestionAnswer {
selected: string | string[];
cancelled: boolean;
+ responseMode: "option" | "custom_input" | "chat_about_this" | "declined";
}
export interface UserQuestionDialogProps {
@@ -138,10 +139,14 @@ export function UserQuestionDialog({
}, [highlightedIndex, optionRowOffsets, listHeight, isEditingCustom, isChatAboutThis, allOptions]);
// Submit the answer
- const submitAnswer = useCallback((values: string[]) => {
+ const submitAnswer = useCallback((
+ values: string[],
+ responseMode: "option" | "custom_input" | "chat_about_this" = "option"
+ ) => {
onAnswer({
selected: question.multiSelect ? values : values[0] ?? "",
cancelled: false,
+ responseMode,
});
}, [question.multiSelect, onAnswer]);
@@ -150,18 +155,23 @@ export function UserQuestionDialog({
onAnswer({
selected: question.multiSelect ? [] : "",
cancelled: true,
+ responseMode: "declined",
});
}, [question.multiSelect, onAnswer]);
// Handle custom text submission - read from textarea ref
const submitCustomText = useCallback(() => {
const text = textareaRef.current?.plainText ?? "";
- if (text.trim()) {
- submitAnswer([text.trim()]);
+ const trimmed = text.trim();
+ if (trimmed || isChatAboutThis) {
+ submitAnswer(
+ [trimmed],
+ isChatAboutThis ? "chat_about_this" : "custom_input"
+ );
}
setIsEditingCustom(false);
setIsChatAboutThis(false);
- }, [submitAnswer]);
+ }, [submitAnswer, isChatAboutThis]);
useKeyboard(
useCallback(
diff --git a/src/ui/index.ts b/src/ui/index.ts
index 37470008..f09c9a17 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -10,7 +10,17 @@
import React from "react";
import { createCliRenderer, type CliRenderer } from "@opentui/core";
import { createRoot, type Root } from "@opentui/react";
-import { ChatApp, type OnToolStart, type OnToolComplete, type OnSkillInvoked, type OnPermissionRequest as ChatOnPermissionRequest, type OnInterrupt, type OnAskUserQuestion } from "./chat.tsx";
+import {
+ ChatApp,
+ type OnToolStart,
+ type OnToolComplete,
+ type OnSkillInvoked,
+ type OnPermissionRequest as ChatOnPermissionRequest,
+ type OnInterrupt,
+ type OnAskUserQuestion,
+ type CommandExecutionTelemetry,
+ type MessageSubmitTelemetry,
+} from "./chat.tsx";
import type { ParallelAgent } from "./components/parallel-agents-tree.tsx";
import { ThemeProvider, darkTheme, type Theme } from "./theme.tsx";
import { AppErrorBoundary } from "./components/error-exit-screen.tsx";
@@ -23,6 +33,10 @@ import type {
} from "../sdk/types.ts";
import { UnifiedModelOperations } from "../models/model-operations.ts";
import { parseTaskToolResult } from "./tools/registry.ts";
+import {
+ createTuiTelemetrySessionTracker,
+ type TuiTelemetrySessionTracker,
+} from "../telemetry/index.ts";
/**
* Build a system prompt section describing all registered capabilities.
@@ -101,6 +115,8 @@ export interface ChatUIConfig {
agentType?: import("../models").AgentType;
/** Initial prompt to auto-submit on session start */
initialPrompt?: string;
+ /** Whether workflow mode was requested for this chat session */
+ workflowEnabled?: boolean;
}
/**
@@ -179,6 +195,8 @@ interface ChatUIState {
* Reset when the model produces non-echo text or starts a new tool.
*/
suppressPostTaskResult: string | null;
+ /** Native TUI telemetry tracker (null when telemetry is disabled or agent type is unknown) */
+ telemetryTracker: TuiTelemetrySessionTracker | null;
}
/**
@@ -264,6 +282,7 @@ export async function startChatUI(
suggestion,
agentType,
initialPrompt,
+ workflowEnabled = false,
} = config;
// Create model operations for the agent
@@ -304,6 +323,13 @@ export async function startChatUI(
parallelAgents: [],
sessionCreationPromise: null,
suppressPostTaskResult: null,
+ telemetryTracker: agentType
+ ? createTuiTelemetrySessionTracker({
+ agentType,
+ workflowEnabled,
+ hasInitialPrompt: !!initialPrompt,
+ })
+ : null,
};
// Create a promise that resolves when the UI exits
@@ -354,10 +380,16 @@ export async function startChatUI(
}
// Resolve the exit promise
+ const duration = Date.now() - state.startTime;
+ state.telemetryTracker?.end({
+ durationMs: duration,
+ messageCount: state.messageCount,
+ });
+
const result: ChatUIResult = {
session: null, // Session already destroyed
messageCount: state.messageCount,
- duration: Date.now() - state.startTime,
+ duration,
};
resolveExit(result);
@@ -432,6 +464,9 @@ export async function startChatUI(
// Subscribe to tool.start events
const unsubStart = client.on("tool.start", (event) => {
const data = event.data as { toolName?: string; toolInput?: unknown; toolUseId?: string; toolUseID?: string };
+ if (data.toolName) {
+ state.telemetryTracker?.trackToolStart(data.toolName);
+ }
if (state.toolStartHandler && data.toolName) {
// Resolve SDK-provided tool use ID (OpenCode: toolUseId, Claude: toolUseID)
const sdkId = data.toolUseId ?? data.toolUseID;
@@ -517,6 +552,9 @@ export async function startChatUI(
// Subscribe to tool.complete events
const unsubComplete = client.on("tool.complete", (event) => {
const data = event.data as { toolName?: string; toolResult?: unknown; success?: boolean; error?: string; toolInput?: Record; toolUseID?: string; toolCallId?: string; toolUseId?: string };
+ if (data.toolName) {
+ state.telemetryTracker?.trackToolComplete(data.toolName, data.success ?? true);
+ }
if (state.toolCompleteHandler) {
// Find the matching tool ID from the stack (FIFO order)
let toolId: string;
@@ -942,6 +980,7 @@ export async function startChatUI(
else if (message.type === "tool_use" && message.content && !state.toolEventsViaHooks) {
const toolContent = message.content as { name?: string; input?: Record; toolUseId?: string };
if (state.toolStartHandler && toolContent.name) {
+ state.telemetryTracker?.trackToolStart(toolContent.name);
// Deduplicate using SDK tool use ID (e.g., Claude's includePartialMessages
// emits multiple assistant messages for the same tool_use block)
const sdkId = toolContent.toolUseId ?? (message.metadata as Record | undefined)?.toolId as string | undefined;
@@ -963,6 +1002,10 @@ export async function startChatUI(
// Skip if we're getting tool events from hooks to avoid duplicates
else if (message.type === "tool_result" && !state.toolEventsViaHooks) {
if (state.toolCompleteHandler) {
+ const toolNameFromMeta = typeof message.metadata?.toolName === "string"
+ ? message.metadata.toolName
+ : "unknown";
+ state.telemetryTracker?.trackToolComplete(toolNameFromMeta, true);
const toolId = `tool_${state.toolIdCounter}`;
state.toolCompleteHandler(
toolId,
@@ -1007,7 +1050,7 @@ export async function startChatUI(
* Handle interrupt request (from signal or UI).
* If streaming, abort the stream. If idle, use double-press to exit.
*/
- function handleInterrupt(): void {
+ function handleInterrupt(sourceType: "ui" | "signal"): void {
// If streaming, abort the current operation
if (state.isStreaming && state.streamAbortController) {
// Skip if already aborted (e.g., keyboard handler already triggered abort
@@ -1018,6 +1061,7 @@ export async function startChatUI(
state.isStreaming = false;
state.parallelAgents = [];
state.streamAbortController.abort();
+ state.telemetryTracker?.trackInterrupt(sourceType);
// Reset interrupt state
state.interruptCount = 0;
if (state.interruptTimeout) {
@@ -1065,7 +1109,7 @@ export async function startChatUI(
// Set up signal handlers for cleanup
// Ctrl+C (SIGINT) uses the unified interrupt handler
const sigintHandler = () => {
- handleInterrupt();
+ handleInterrupt("signal");
};
const sigtermHandler = () => {
@@ -1162,7 +1206,7 @@ export async function startChatUI(
* This is called by ChatApp when user presses interrupt keys.
*/
const handleInterruptFromUI = () => {
- handleInterrupt();
+ handleInterrupt("ui");
};
/**
@@ -1202,6 +1246,24 @@ export async function startChatUI(
}
};
+ /**
+ * Update MCP servers for future session creation.
+ * Toggle changes from /mcp apply on the next session reset/reconnect.
+ */
+ const handleSessionMcpServersChange = (servers: SessionConfig["mcpServers"]) => {
+ if (sessionConfig) {
+ sessionConfig.mcpServers = servers;
+ }
+ };
+
+ const handleCommandTelemetry = (event: CommandExecutionTelemetry) => {
+ state.telemetryTracker?.trackCommandExecution(event);
+ };
+
+ const handleMessageTelemetry = (event: MessageSubmitTelemetry) => {
+ state.telemetryTracker?.trackMessageSubmit(event);
+ };
+
state.root.render(
React.createElement(
ThemeProvider,
@@ -1241,6 +1303,9 @@ export async function startChatUI(
createSubagentSession,
initialPrompt,
onModelChange: handleModelChange,
+ onSessionMcpServersChange: handleSessionMcpServersChange,
+ onCommandExecutionTelemetry: handleCommandTelemetry,
+ onMessageSubmitTelemetry: handleMessageTelemetry,
}),
}
),
diff --git a/src/ui/utils/hitl-response.test.ts b/src/ui/utils/hitl-response.test.ts
new file mode 100644
index 00000000..61f2d551
--- /dev/null
+++ b/src/ui/utils/hitl-response.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from "bun:test";
+import {
+ getHitlResponseRecord,
+ normalizeHitlAnswer,
+ type HitlResponseRecord,
+} from "./hitl-response.ts";
+
+describe("normalizeHitlAnswer", () => {
+ test("renders empty answers explicitly", () => {
+ const result = normalizeHitlAnswer({
+ selected: "",
+ cancelled: false,
+ responseMode: "option",
+ });
+
+ expect(result.answerText).toBe("");
+ expect(result.displayText).toBe('User answered: ""');
+ expect(result.cancelled).toBe(false);
+ });
+
+ test("renders declined response", () => {
+ const result = normalizeHitlAnswer({
+ selected: "",
+ cancelled: true,
+ responseMode: "declined",
+ });
+
+ expect(result.displayText).toBe("User declined to answer question.");
+ expect(result.cancelled).toBe(true);
+ expect(result.responseMode).toBe("declined");
+ });
+
+ test("renders chat-about-this response", () => {
+ const result = normalizeHitlAnswer({
+ selected: "Could we compare options first?",
+ cancelled: false,
+ responseMode: "chat_about_this",
+ });
+
+ expect(result.displayText).toBe('User decided to chat more about options: "Could we compare options first?"');
+ });
+});
+
+describe("getHitlResponseRecord", () => {
+ test("extracts legacy output shape", () => {
+ const result = getHitlResponseRecord({
+ output: {
+ answer: "",
+ cancelled: false,
+ },
+ });
+
+ expect(result).toBeTruthy();
+ expect(result?.displayText).toBe('User answered: ""');
+ });
+
+ test("prefers structured hitlResponse field", () => {
+ const record: HitlResponseRecord = {
+ cancelled: false,
+ responseMode: "custom_input",
+ answerText: "manual text",
+ displayText: 'User answered: "manual text"',
+ };
+
+ const result = getHitlResponseRecord({
+ hitlResponse: record,
+ output: { answer: "stale" },
+ });
+
+ expect(result).toEqual(record);
+ });
+});
diff --git a/src/ui/utils/hitl-response.ts b/src/ui/utils/hitl-response.ts
new file mode 100644
index 00000000..cccd2172
--- /dev/null
+++ b/src/ui/utils/hitl-response.ts
@@ -0,0 +1,107 @@
+/**
+ * HITL response utilities for AskUserQuestion/question tool rendering.
+ */
+
+export type HitlResponseMode = "option" | "custom_input" | "chat_about_this" | "declined";
+
+export interface HitlResponseRecord {
+ cancelled: boolean;
+ responseMode: HitlResponseMode;
+ answerText: string;
+ displayText: string;
+}
+
+export interface HitlAnswerInput {
+ selected: string | string[];
+ cancelled: boolean;
+ responseMode?: HitlResponseMode;
+}
+
+interface HitlOutputShape {
+ answer?: string | null;
+ cancelled?: boolean;
+ responseMode?: HitlResponseMode;
+ displayText?: string;
+}
+
+function toAnswerText(selected: string | string[]): string {
+ return Array.isArray(selected) ? selected.join(", ") : String(selected ?? "");
+}
+
+export function formatHitlDisplayText(response: {
+ cancelled: boolean;
+ responseMode: HitlResponseMode;
+ answerText: string;
+}): string {
+ if (response.cancelled || response.responseMode === "declined") {
+ return "User declined to answer question.";
+ }
+
+ if (response.responseMode === "chat_about_this") {
+ return response.answerText.length > 0
+ ? `User decided to chat more about options: "${response.answerText}"`
+ : "User decided to chat more about options.";
+ }
+
+ return `User answered: "${response.answerText}"`;
+}
+
+export function normalizeHitlAnswer(answer: HitlAnswerInput): HitlResponseRecord {
+ const responseMode = answer.cancelled
+ ? "declined"
+ : (answer.responseMode ?? "option");
+ const cancelled = answer.cancelled || responseMode === "declined";
+ const answerText = cancelled ? "" : toAnswerText(answer.selected);
+
+ return {
+ cancelled,
+ responseMode,
+ answerText,
+ displayText: formatHitlDisplayText({ cancelled, responseMode, answerText }),
+ };
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+export function getHitlResponseRecord(toolCall: {
+ hitlResponse?: HitlResponseRecord;
+ output?: unknown;
+}): HitlResponseRecord | null {
+ if (toolCall.hitlResponse) {
+ return toolCall.hitlResponse;
+ }
+
+ if (!isRecord(toolCall.output)) {
+ return null;
+ }
+
+ const output = toolCall.output as HitlOutputShape;
+ const hasLegacyFields = (
+ Object.hasOwn(output, "answer")
+ || Object.hasOwn(output, "cancelled")
+ || Object.hasOwn(output, "responseMode")
+ || Object.hasOwn(output, "displayText")
+ );
+ if (!hasLegacyFields) {
+ return null;
+ }
+
+ const cancelled = output.cancelled ?? false;
+ const responseMode = cancelled
+ ? "declined"
+ : (output.responseMode ?? "option");
+ const answerText = cancelled ? "" : String(output.answer ?? "");
+
+ return {
+ cancelled,
+ responseMode,
+ answerText,
+ displayText: output.displayText ?? formatHitlDisplayText({
+ cancelled,
+ responseMode,
+ answerText,
+ }),
+ };
+}
diff --git a/src/ui/utils/mcp-output.test.ts b/src/ui/utils/mcp-output.test.ts
new file mode 100644
index 00000000..967ec28b
--- /dev/null
+++ b/src/ui/utils/mcp-output.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, test } from "bun:test";
+import type { McpRuntimeSnapshot, McpServerConfig } from "../../sdk/types.ts";
+import {
+ applyMcpServerToggles,
+ buildMcpSnapshotView,
+ getActiveMcpServers,
+} from "./mcp-output.ts";
+
+describe("mcp-output helpers", () => {
+ test("applies toggle overrides and marks session disable reason", () => {
+ const servers: McpServerConfig[] = [
+ { name: "deepwiki", type: "http", url: "https://mcp.deepwiki.com/mcp", enabled: true },
+ ];
+
+ const toggled = applyMcpServerToggles(servers, { deepwiki: false });
+ expect(toggled[0]?.enabled).toBe(false);
+ expect(toggled[0]?.disabledReason).toBe("Disabled for this session");
+ });
+
+ test("returns only enabled servers for next session config", () => {
+ const servers: McpServerConfig[] = [
+ { name: "deepwiki", enabled: true },
+ { name: "filesystem", enabled: true },
+ ];
+
+ const active = getActiveMcpServers(servers, { filesystem: false });
+ expect(active.map((server) => server.name)).toEqual(["deepwiki"]);
+ });
+
+ test("builds sorted snapshot and masks sensitive values", () => {
+ const servers: McpServerConfig[] = [
+ {
+ name: "zeta",
+ type: "stdio",
+ command: "npx",
+ args: ["-y", "zeta-mcp"],
+ env: { API_TOKEN: "secret" },
+ enabled: true,
+ },
+ {
+ name: "alpha",
+ type: "http",
+ url: "https://alpha.example/mcp",
+ headers: { Authorization: "Bearer secret" },
+ enabled: true,
+ },
+ ];
+
+ const runtimeSnapshot: McpRuntimeSnapshot = {
+ servers: {
+ alpha: {
+ authStatus: "OAuth",
+ tools: ["search"],
+ },
+ },
+ };
+
+ const snapshot = buildMcpSnapshotView({ servers, runtimeSnapshot });
+ expect(snapshot.hasConfiguredServers).toBe(true);
+ expect(snapshot.servers.map((server) => server.name)).toEqual(["alpha", "zeta"]);
+ expect(snapshot.servers[0]?.transport.httpHeaders).toBe("Authorization=*****");
+ expect(snapshot.servers[0]?.authStatus).toBe("OAuth");
+ expect(snapshot.servers[1]?.transport.env).toBe("API_TOKEN=*****");
+ expect(snapshot.servers[1]?.authStatus).toBe("Unknown");
+ });
+
+ test("normalizes Claude MCP tool names to Codex-style tool labels", () => {
+ const servers: McpServerConfig[] = [
+ { name: "deepwiki", enabled: true },
+ ];
+
+ const runtimeSnapshot: McpRuntimeSnapshot = {
+ servers: {
+ deepwiki: {
+ tools: [
+ "mcp__deepwiki__ask_question",
+ "ask_question",
+ "mcp__deepwiki__read_page",
+ ],
+ },
+ },
+ };
+
+ const snapshot = buildMcpSnapshotView({ servers, runtimeSnapshot });
+ expect(snapshot.servers[0]?.tools).toEqual(["ask_question", "read_page"]);
+ });
+
+ test("produces no-server empty snapshot", () => {
+ const snapshot = buildMcpSnapshotView({ servers: [] });
+ expect(snapshot.hasConfiguredServers).toBe(false);
+ expect(snapshot.servers).toEqual([]);
+ });
+
+ test("wildcard tools ['*'] makes noToolsAvailable false", () => {
+ const servers: McpServerConfig[] = [
+ { name: "deepwiki", type: "http", url: "https://mcp.deepwiki.com/mcp", tools: ["*"], enabled: true },
+ ];
+
+ const snapshot = buildMcpSnapshotView({ servers });
+ expect(snapshot.noToolsAvailable).toBe(false);
+ expect(snapshot.servers[0]?.tools).toEqual(["*"]);
+ });
+
+ test("runtime tools override wildcard config tools", () => {
+ const servers: McpServerConfig[] = [
+ { name: "deepwiki", type: "http", url: "https://mcp.deepwiki.com/mcp", tools: ["*"], enabled: true },
+ ];
+
+ const runtimeSnapshot: McpRuntimeSnapshot = {
+ servers: {
+ deepwiki: {
+ tools: ["mcp__deepwiki__ask_question", "mcp__deepwiki__read_wiki_structure"],
+ },
+ },
+ };
+
+ const snapshot = buildMcpSnapshotView({ servers, runtimeSnapshot });
+ expect(snapshot.servers[0]?.tools).toEqual(["ask_question", "read_wiki_structure"]);
+ expect(snapshot.noToolsAvailable).toBe(false);
+ });
+
+ test("config tools whitelist filters runtime tools", () => {
+ const servers: McpServerConfig[] = [
+ { name: "deepwiki", type: "http", url: "https://mcp.deepwiki.com/mcp", tools: ["ask_question"], enabled: true },
+ ];
+
+ const runtimeSnapshot: McpRuntimeSnapshot = {
+ servers: {
+ deepwiki: {
+ tools: ["mcp__deepwiki__ask_question", "mcp__deepwiki__read_wiki_structure", "mcp__deepwiki__read_wiki_contents"],
+ },
+ },
+ };
+
+ const snapshot = buildMcpSnapshotView({ servers, runtimeSnapshot });
+ expect(snapshot.servers[0]?.tools).toEqual(["ask_question"]);
+ });
+});
diff --git a/src/ui/utils/mcp-output.ts b/src/ui/utils/mcp-output.ts
new file mode 100644
index 00000000..e5aecde6
--- /dev/null
+++ b/src/ui/utils/mcp-output.ts
@@ -0,0 +1,214 @@
+import type {
+ McpRuntimeServerSnapshot,
+ McpRuntimeSnapshot,
+ McpServerConfig,
+} from "../../sdk/types.ts";
+
+export type McpAuthStatusView = "Unsupported" | "Not logged in" | "Bearer token" | "OAuth" | "Unknown";
+
+export interface McpResourceView {
+ label: string;
+ uri: string;
+}
+
+export interface McpResourceTemplateView {
+ label: string;
+ uriTemplate: string;
+}
+
+export interface McpTransportView {
+ kind: "stdio" | "http" | "sse" | "unknown";
+ commandLine?: string;
+ cwd?: string;
+ env?: string;
+ url?: string;
+ httpHeaders?: string;
+ envHttpHeaders?: string;
+}
+
+export interface McpServerView {
+ name: string;
+ enabled: boolean;
+ disabledReason?: string;
+ authStatus: McpAuthStatusView;
+ transport: McpTransportView;
+ tools: string[];
+ resources: McpResourceView[];
+ resourceTemplates: McpResourceTemplateView[];
+}
+
+export interface McpSnapshotView {
+ commandLabel: string;
+ heading: string;
+ docsHint: string;
+ hasConfiguredServers: boolean;
+ noToolsAvailable: boolean;
+ servers: McpServerView[];
+}
+
+export type McpServerToggleMap = Record;
+
+interface BuildMcpSnapshotInput {
+ servers: McpServerConfig[];
+ toggles?: McpServerToggleMap;
+ runtimeSnapshot?: McpRuntimeSnapshot | null;
+}
+
+function sortByName(items: T[]): T[] {
+ return [...items].sort((a, b) => a.name.localeCompare(b.name));
+}
+
+function normalizeToolName(serverName: string, toolName: string): string {
+ const prefix = `mcp__${serverName.toLowerCase()}__`;
+ const normalized = toolName.trim();
+ if (normalized.toLowerCase().startsWith(prefix)) {
+ return normalized.slice(prefix.length);
+ }
+ return normalized;
+}
+
+function normalizeToolNames(serverName: string, toolNames: string[] | undefined): string[] {
+ if (!toolNames || toolNames.length === 0) return [];
+ return [...new Set(toolNames.map((name) => normalizeToolName(serverName, name)).filter((name) => name.length > 0))]
+ .sort((a, b) => a.localeCompare(b));
+}
+
+function maskPairValues(values: Record | undefined): string {
+ if (!values) return "-";
+ const names = Object.keys(values).sort((a, b) => a.localeCompare(b));
+ if (names.length === 0) return "-";
+ return names.map((name) => `${name}=*****`).join(", ");
+}
+
+function formatEnvHeaderBindings(values: Record | undefined): string {
+ if (!values) return "-";
+ const entries = Object.entries(values).sort((a, b) => a[0].localeCompare(b[0]));
+ if (entries.length === 0) return "-";
+ return entries.map(([header, envVar]) => `${header}=${envVar}`).join(", ");
+}
+
+function normalizeAuthStatus(status: string | undefined): McpAuthStatusView {
+ if (!status) return "Unknown";
+ if (status === "Unsupported" || status === "Not logged in" || status === "Bearer token" || status === "OAuth") {
+ return status;
+ }
+ return "Unknown";
+}
+
+function getRuntimeServerSnapshot(
+ runtimeSnapshot: McpRuntimeSnapshot | null | undefined,
+ serverName: string
+): McpRuntimeServerSnapshot | undefined {
+ if (!runtimeSnapshot) return undefined;
+ if (runtimeSnapshot.servers[serverName]) {
+ return runtimeSnapshot.servers[serverName];
+ }
+ const lower = serverName.toLowerCase();
+ for (const [name, server] of Object.entries(runtimeSnapshot.servers)) {
+ if (name.toLowerCase() === lower) {
+ return server;
+ }
+ }
+ return undefined;
+}
+
+function formatTransport(server: McpServerConfig, runtimeServer?: McpRuntimeServerSnapshot): McpTransportView {
+ const kind = server.type ?? (server.url ? "http" : server.command ? "stdio" : "unknown");
+
+ if (kind === "stdio") {
+ const args = server.args?.length ? ` ${server.args.join(" ")}` : "";
+ return {
+ kind,
+ commandLine: `${server.command ?? "(none)"}${args}`,
+ cwd: server.cwd,
+ env: maskPairValues(server.env),
+ };
+ }
+
+ if (kind === "http" || kind === "sse") {
+ const maskedHeaders = runtimeServer?.httpHeaders ? maskPairValues(runtimeServer.httpHeaders) : maskPairValues(server.headers);
+ const envHttpHeaders = runtimeServer?.envHttpHeaders ? formatEnvHeaderBindings(runtimeServer.envHttpHeaders) : "-";
+ return {
+ kind,
+ url: server.url,
+ httpHeaders: maskedHeaders,
+ envHttpHeaders,
+ };
+ }
+
+ return { kind: "unknown" };
+}
+
+export function applyMcpServerToggles(servers: McpServerConfig[], toggles: McpServerToggleMap = {}): McpServerConfig[] {
+ const normalized = new Map(
+ Object.entries(toggles).map(([name, enabled]) => [name.toLowerCase(), enabled])
+ );
+
+ return servers.map((server) => {
+ const override = normalized.get(server.name.toLowerCase());
+ if (override === undefined) return server;
+ return {
+ ...server,
+ enabled: override,
+ disabledReason: override ? undefined : "Disabled for this session",
+ };
+ });
+}
+
+export function getActiveMcpServers(servers: McpServerConfig[], toggles: McpServerToggleMap = {}): McpServerConfig[] {
+ return applyMcpServerToggles(servers, toggles).filter((server) => server.enabled !== false);
+}
+
+export function buildMcpSnapshotView({
+ servers,
+ toggles = {},
+ runtimeSnapshot = null,
+}: BuildMcpSnapshotInput): McpSnapshotView {
+ const toggledServers = applyMcpServerToggles(servers, toggles);
+ const sortedServers = sortByName(toggledServers);
+
+ const snapshotServers: McpServerView[] = sortedServers.map((server) => {
+ const runtimeServer = getRuntimeServerSnapshot(runtimeSnapshot, server.name);
+ const configTools = normalizeToolNames(server.name, server.tools);
+ const isWildcard = !server.tools || (configTools.length === 1 && configTools[0] === "*");
+ let tools: string[];
+ if (runtimeServer?.tools) {
+ const runtimeTools = normalizeToolNames(server.name, runtimeServer.tools);
+ tools = isWildcard ? runtimeTools : runtimeTools.filter((t) => configTools.includes(t));
+ } else {
+ tools = configTools;
+ }
+ const resources = runtimeServer?.resources
+ ? runtimeServer.resources.map((resource) => ({
+ label: resource.title ?? resource.name,
+ uri: resource.uri,
+ })).sort((a, b) => a.label.localeCompare(b.label))
+ : [];
+ const resourceTemplates = runtimeServer?.resourceTemplates
+ ? runtimeServer.resourceTemplates.map((template) => ({
+ label: template.title ?? template.name,
+ uriTemplate: template.uriTemplate,
+ })).sort((a, b) => a.label.localeCompare(b.label))
+ : [];
+
+ return {
+ name: server.name,
+ enabled: server.enabled !== false,
+ disabledReason: server.disabledReason,
+ authStatus: normalizeAuthStatus(runtimeServer?.authStatus),
+ transport: formatTransport(server, runtimeServer),
+ tools,
+ resources,
+ resourceTemplates,
+ };
+ });
+
+ return {
+ commandLabel: "/mcp",
+ heading: "🔌 MCP Tools",
+ docsHint: "See the MCP docs to configure them.",
+ hasConfiguredServers: snapshotServers.length > 0,
+ noToolsAvailable: snapshotServers.length > 0 && snapshotServers.every((server) => server.tools.length === 0),
+ servers: snapshotServers,
+ };
+}
diff --git a/src/ui/utils/transcript-formatter.hitl.test.ts b/src/ui/utils/transcript-formatter.hitl.test.ts
new file mode 100644
index 00000000..1ab61c4b
--- /dev/null
+++ b/src/ui/utils/transcript-formatter.hitl.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, test } from "bun:test";
+import { formatTranscript } from "./transcript-formatter.ts";
+import type { ChatMessage } from "../chat.tsx";
+
+describe("formatTranscript HITL rendering", () => {
+ test("renders canonical HITL response text instead of raw JSON", () => {
+ const msg: ChatMessage = {
+ id: "m1",
+ role: "assistant",
+ content: "",
+ timestamp: new Date().toISOString(),
+ toolCalls: [
+ {
+ id: "t1",
+ toolName: "question",
+ status: "completed",
+ input: {
+ question: "Pick one",
+ },
+ output: {
+ answer: "",
+ cancelled: false,
+ },
+ },
+ ],
+ };
+
+ const lines = formatTranscript({
+ messages: [msg],
+ isStreaming: false,
+ });
+
+ const rendered = lines.map((line) => line.content).join("\n");
+ expect(rendered).toContain('User answered: ""');
+ expect(rendered).not.toContain('{"answer"');
+ });
+});
diff --git a/src/ui/utils/transcript-formatter.ts b/src/ui/utils/transcript-formatter.ts
index 2e3f4c07..82cc4bd3 100644
--- a/src/ui/utils/transcript-formatter.ts
+++ b/src/ui/utils/transcript-formatter.ts
@@ -9,6 +9,7 @@ import type { ChatMessage, StreamingMeta } from "../chat.tsx";
import type { ParallelAgent } from "../components/parallel-agents-tree.tsx";
import { formatDuration } from "../components/parallel-agents-tree.tsx";
import { truncateText, formatTimestamp as formatTimestampFull } from "./format.ts";
+import { getHitlResponseRecord } from "./hitl-response.ts";
import { STATUS, TREE, CONNECTOR, PROMPT, SPINNER_FRAMES, SPINNER_COMPLETE, SEPARATOR, MISC } from "../constants/icons.ts";
// ============================================================================
@@ -134,6 +135,34 @@ export function formatTranscript(options: FormatTranscriptOptions): TranscriptLi
// Tool calls
if (msg.toolCalls && msg.toolCalls.length > 0) {
for (const tc of msg.toolCalls) {
+ const isHitlTool = tc.toolName === "AskUserQuestion"
+ || tc.toolName === "question"
+ || tc.toolName === "ask_user";
+ if (isHitlTool) {
+ const statusIcon = tc.status === "completed"
+ ? STATUS.active
+ : tc.status === "running"
+ ? STATUS.active
+ : tc.status === "error"
+ ? STATUS.error
+ : STATUS.pending;
+ lines.push(line("tool-header", `${statusIcon} ${tc.toolName}`));
+
+ const questions = tc.input.questions as Array<{ question?: string }> | undefined;
+ const questionText = (tc.input.question as string)
+ || questions?.[0]?.question
+ || "";
+ if (questionText) {
+ lines.push(line("tool-content", ` ${questionText}`, 1));
+ }
+
+ const hitlResponse = getHitlResponseRecord(tc);
+ if (hitlResponse) {
+ lines.push(line("tool-content", ` ${PROMPT.cursor} ${hitlResponse.displayText}`, 1));
+ }
+ continue;
+ }
+
const statusIcon = tc.status === "completed" ? STATUS.active : tc.status === "running" ? STATUS.active : tc.status === "error" ? STATUS.error : STATUS.pending;
const toolTitle = formatToolTitle(tc.toolName, tc.input);
lines.push(line("tool-header", `${statusIcon} ${tc.toolName} ${toolTitle}`));
diff --git a/src/utils/mcp-config.ts b/src/utils/mcp-config.ts
index 0603349c..9ce678bc 100644
--- a/src/utils/mcp-config.ts
+++ b/src/utils/mcp-config.ts
@@ -30,7 +30,7 @@ export function parseClaudeMcpConfig(filePath: string): McpServerConfig[] {
url: cfg.url as string | undefined,
headers: cfg.headers as Record | undefined,
tools: Array.isArray(cfg.tools) ? (cfg.tools as string[]) : undefined,
- enabled: true,
+ enabled: cfg.enabled !== false,
}));
} catch {
return [];
@@ -61,7 +61,7 @@ export function parseCopilotMcpConfig(filePath: string): McpServerConfig[] {
cwd: cfg.cwd as string | undefined,
timeout: cfg.timeout as number | undefined,
tools: Array.isArray(cfg.tools) ? (cfg.tools as string[]) : undefined,
- enabled: true,
+ enabled: cfg.enabled !== false,
};
});
} catch {
@@ -136,44 +136,81 @@ const BUILTIN_MCP_SERVERS: McpServerConfig[] = [
/**
* Discover and load MCP server configs from all known config file locations.
- * Deduplicates by server name — later sources override earlier ones.
*
- * Discovery order (lowest to highest priority):
- * 1. Built-in defaults (deepwiki with ask_question only)
- * 2. User-level configs (~/.claude/.mcp.json, ~/.copilot/mcp-config.json, ~/.github/mcp-config.json)
- * 3. Project-level configs (.mcp.json, .copilot/mcp-config.json, .github/mcp-config.json, opencode.json, opencode.jsonc, .opencode/opencode.json)
+ * Configs from different ecosystems (Claude, Copilot, OpenCode) are independent:
+ * within the same ecosystem, later sources (project-level) override earlier ones
+ * (user-level), but configs from one ecosystem never override another's.
+ * Builtins can be overridden by any ecosystem.
+ *
+ * Discovery order per ecosystem:
+ * 1. Built-in defaults (overridable by any ecosystem)
+ * 2. Claude: ~/.claude/.mcp.json → .mcp.json
+ * 3. Copilot: ~/.copilot/mcp-config.json → .github/mcp-config.json, mcp-config.json
+ * 4. OpenCode: opencode.json, opencode.jsonc, .opencode/opencode.json
*
* @param cwd - Project root directory (defaults to process.cwd())
* @returns Deduplicated array of McpServerConfig
*/
-export function discoverMcpConfigs(cwd?: string): McpServerConfig[] {
+export interface DiscoverMcpConfigsOptions {
+ includeDisabled?: boolean;
+}
+
+type ConfigEcosystem = "builtin" | "claude" | "copilot" | "opencode";
+
+interface TaggedSource {
+ config: McpServerConfig;
+ ecosystem: ConfigEcosystem;
+}
+
+export function discoverMcpConfigs(cwd?: string, options: DiscoverMcpConfigsOptions = {}): McpServerConfig[] {
const projectRoot = cwd ?? process.cwd();
const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "";
- const sources: McpServerConfig[] = [];
+ const sources: TaggedSource[] = [];
+
+ function addSources(configs: McpServerConfig[], ecosystem: ConfigEcosystem): void {
+ for (const config of configs) {
+ sources.push({ config, ecosystem });
+ }
+ }
- // Built-in defaults (lowest priority)
- sources.push(...BUILTIN_MCP_SERVERS);
+ // Built-in defaults (overridable by any ecosystem)
+ addSources(BUILTIN_MCP_SERVERS, "builtin");
- // User-level configs
- sources.push(...parseClaudeMcpConfig(join(homeDir, ".claude", ".mcp.json")));
- sources.push(...parseCopilotMcpConfig(join(homeDir, ".copilot", "mcp-config.json")));
- sources.push(...parseCopilotMcpConfig(join(homeDir, ".github", "mcp-config.json")));
+ // User-level configs (lowest priority within each ecosystem)
+ addSources(parseClaudeMcpConfig(join(homeDir, ".claude", ".mcp.json")), "claude");
+ addSources(parseCopilotMcpConfig(join(homeDir, ".copilot", "mcp-config.json")), "copilot");
- // Project-level configs (higher priority — override user-level)
- sources.push(...parseClaudeMcpConfig(join(projectRoot, ".mcp.json")));
- sources.push(...parseCopilotMcpConfig(join(projectRoot, ".copilot", "mcp-config.json")));
- sources.push(...parseCopilotMcpConfig(join(projectRoot, ".github", "mcp-config.json")));
- sources.push(...parseOpenCodeMcpConfig(join(projectRoot, "opencode.json")));
- sources.push(...parseOpenCodeMcpConfig(join(projectRoot, "opencode.jsonc")));
- sources.push(...parseOpenCodeMcpConfig(join(projectRoot, ".opencode", "opencode.json")));
+ // Project-level configs (override user-level within the same ecosystem)
+ addSources(parseClaudeMcpConfig(join(projectRoot, ".mcp.json")), "claude");
+ addSources(parseCopilotMcpConfig(join(projectRoot, ".github", "mcp-config.json")), "copilot");
+ addSources(parseCopilotMcpConfig(join(projectRoot, "mcp-config.json")), "copilot");
+ addSources(parseOpenCodeMcpConfig(join(projectRoot, "opencode.json")), "opencode");
+ addSources(parseOpenCodeMcpConfig(join(projectRoot, "opencode.jsonc")), "opencode");
+ addSources(parseOpenCodeMcpConfig(join(projectRoot, ".opencode", "opencode.json")), "opencode");
+
+ // Deduplicate by name with ecosystem isolation.
+ // A source can override the existing entry only if:
+ // - the existing entry is from "builtin", OR
+ // - the source is from the same ecosystem
+ const byName = new Map();
+ for (const entry of sources) {
+ const existing = byName.get(entry.config.name);
+ if (!existing || existing.ecosystem === "builtin" || existing.ecosystem === entry.ecosystem) {
+ byName.set(entry.config.name, entry);
+ }
+ // else: different non-builtin ecosystem already owns this name — skip
+ }
- // Deduplicate by name (last wins)
- const byName = new Map();
- for (const server of sources) {
- byName.set(server.name, server);
+ // Apply default: missing tools means all tools are available.
+ const allServers = Array.from(byName.values()).map((s) => ({
+ ...s.config,
+ tools: s.config.tools ?? ["*"],
+ }));
+ if (options.includeDisabled) {
+ return allServers;
}
- // Filter out disabled servers
- return Array.from(byName.values()).filter(s => s.enabled !== false);
+ // Default behavior: only return enabled servers.
+ return allServers.filter(s => s.enabled !== false);
}
diff --git a/src/utils/telemetry/types.ts b/src/utils/telemetry/types.ts
deleted file mode 100644
index 56cbacb1..00000000
--- a/src/utils/telemetry/types.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * Telemetry types for anonymous usage tracking
- *
- * Schema follows the spec in Section 5.1 of the telemetry implementation document.
- */
-
-/**
- * Persistent telemetry state stored in telemetry.json
- */
-export interface TelemetryState {
- /** Master toggle for telemetry collection */
- enabled: boolean;
- /** Has user explicitly consented to telemetry? */
- consentGiven: boolean;
- /** Anonymous UUID v4 for session correlation */
- anonymousId: string;
- /** ISO 8601 timestamp when state was first created */
- createdAt: string;
- /** ISO 8601 timestamp of last ID rotation */
- rotatedAt: string;
-}
-
-/**
- * Atomic CLI command types that are tracked
- * Reference: Spec Section 5.3.1
- */
-export type AtomicCommandType = "init" | "update" | "uninstall" | "run";
-
-/**
- * Agent types supported by Atomic
- */
-export type AgentType = "claude" | "opencode" | "copilot";
-
-/**
- * Event logged when an Atomic CLI command is executed.
- * Reference: Spec Section 5.3.1
- */
-export interface AtomicCommandEvent {
- /** Anonymous UUID v4 for user correlation, rotated monthly */
- anonymousId: string;
- /** Unique UUID v4 for this specific event */
- eventId: string;
- /** Event type discriminator */
- eventType: "atomic_command";
- /** ISO 8601 timestamp when event occurred */
- timestamp: string;
- /** The Atomic CLI command that was executed */
- command: AtomicCommandType;
- /** The agent type selected (null for agent-agnostic commands) */
- agentType: AgentType | null;
- /** Whether the command succeeded */
- success: boolean;
- /** Operating system platform */
- platform: NodeJS.Platform;
- /** Atomic CLI version */
- atomicVersion: string;
- /** Source of the event (always 'cli' for CLI commands) */
- source: "cli";
-}
-
-/**
- * Event logged when CLI args contain slash commands.
- * Reference: Spec Section 5.3.2
- */
-export interface CliCommandEvent {
- /** Anonymous UUID v4 for user correlation, rotated monthly */
- anonymousId: string;
- /** Unique UUID v4 for this specific event */
- eventId: string;
- /** Event type discriminator */
- eventType: "cli_command";
- /** ISO 8601 timestamp when event occurred */
- timestamp: string;
- /** The agent type being invoked */
- agentType: AgentType;
- /** Array of slash commands found in CLI args */
- commands: string[];
- /** Number of commands (for quick aggregation) */
- commandCount: number;
- /** Operating system platform */
- platform: NodeJS.Platform;
- /** Atomic CLI version */
- atomicVersion: string;
- /** Source of the event (always 'cli' for CLI commands) */
- source: "cli";
-}
-
-/**
- * Event logged when an agent session ends.
- * Tracked via agent-specific hooks (Claude Code Stop hook, Copilot CLI sessionEnd, OpenCode plugin).
- * Reference: Spec Section 5.3.3
- */
-export interface AgentSessionEvent {
- /** Anonymous UUID v4 for user correlation, rotated monthly */
- anonymousId: string;
- /** Unique UUID v4 for this specific event */
- eventId: string;
- /** Unique UUID v4 for this specific session (same as eventId for session events) */
- sessionId: string;
- /** Event type discriminator */
- eventType: "agent_session";
- /** ISO 8601 timestamp when session ended */
- timestamp: string;
- /** The agent type that was running */
- agentType: AgentType;
- /** Array of Atomic slash commands used during the session */
- commands: string[];
- /** Number of commands (for quick aggregation) */
- commandCount: number;
- /** Operating system platform */
- platform: NodeJS.Platform;
- /** Atomic CLI version */
- atomicVersion: string;
- /** Source of the event (always 'session_hook' for session events) */
- source: "session_hook";
-}
-
-/**
- * Union type for all telemetry events.
- * Extensible to support additional event types.
- */
-export type TelemetryEvent = AtomicCommandEvent | CliCommandEvent | AgentSessionEvent;
\ No newline at end of file