diff --git a/.claude/commands/ccw.md b/.claude/commands/ccw.md
index 735dccab..aba61a81 100644
--- a/.claude/commands/ccw.md
+++ b/.claude/commands/ccw.md
@@ -11,42 +11,39 @@ Main process orchestrator: intent analysis → workflow selection → command ch
## Skill 映射
-命令链中的 workflow 操作通过 `Skill()` 调用。
+命令链中的 workflow 操作通过 `Skill()` 调用。每个 Skill 是自包含的执行单元,内部处理完整流水线。
-| Skill | 包含操作 |
-|-------|---------|
-| `workflow-lite-plan` | lite-plan, lite-execute |
-| `workflow-plan` | plan, plan-verify, replan |
-| `workflow-execute` | execute |
-| `workflow-multi-cli-plan` | multi-cli-plan |
-| `workflow-test-fix` | test-fix-gen, test-cycle-execute |
-| `workflow-tdd` | tdd-plan, tdd-verify |
-| `review-cycle` | review-session-cycle, review-module-cycle, review-cycle-fix |
-| `brainstorm` | auto-parallel, artifacts, role-analysis, synthesis |
+| Skill | 内部流水线 |
+|-------|-----------|
+| `workflow-lite-plan` | explore → plan → confirm → execute |
+| `workflow-plan` | session → context → convention → gen → verify/replan |
+| `workflow-execute` | session discovery → task processing → commit |
+| `workflow-tdd` | 6-phase TDD plan → verify |
+| `workflow-test-fix` | session → context → analysis → gen → cycle |
+| `workflow-multi-cli-plan` | ACE context → CLI discussion → plan → execute |
+| `review-cycle` | session/module review → fix orchestration |
+| `brainstorm` | auto/single-role → artifacts → analysis → synthesis |
-独立命令:workflow:brainstorm-with-file, workflow:debug-with-file, workflow:analyze-with-file, issue:*
+独立命令(仍使用 colon 格式):workflow:brainstorm-with-file, workflow:debug-with-file, workflow:analyze-with-file, workflow:ui-design:*, issue:*, workflow:session:*
-## Core Concept: Minimum Execution Units (最小执行单元)
+## Core Concept: Self-Contained Skills (自包含 Skill)
-**Definition**: A set of commands that must execute together as an atomic group to achieve a meaningful workflow milestone.
+**Definition**: 每个 Skill 内部处理完整流水线,是天然的最小执行单元。单次 Skill 调用即完成一个有意义的工作里程碑。
**Why This Matters**:
-- **Prevents Incomplete States**: Avoid stopping after task generation without execution
+- **Prevents Incomplete States**: 每个 Skill 内部保证端到端完整性
- **User Experience**: User gets complete results, not intermediate artifacts requiring manual follow-up
-- **Workflow Integrity**: Maintains logical coherence of multi-step operations
+- **Simplified Orchestration**: 命令链只需组合独立 Skill,无需关注内部步骤
**Key Units in CCW**:
-| Unit Type | Pattern | Example |
-|-----------|---------|---------|
-| **Planning + Execution** | plan-cmd → execute-cmd | lite-plan → lite-execute |
-| **Testing** | test-gen-cmd → test-exec-cmd | test-fix-gen → test-cycle-execute |
-| **Review** | review-cmd → fix-cmd | review-session-cycle → review-cycle-fix |
-
-**Atomic Rules**:
-1. CCW automatically groups commands into minimum units - never splits them
-2. Pipeline visualization shows units with `【 】` markers
-3. Error handling preserves unit boundaries (retry/skip affects whole unit)
+| 单元类型 | Skill | 说明 |
+|---------|-------|------|
+| 轻量 Plan+Execute | `workflow-lite-plan` | 内部完成 plan→execute |
+| 标准 Planning | `workflow-plan` → `workflow-execute` | plan 和 execute 是独立 Skill |
+| TDD Planning | `workflow-tdd` → `workflow-execute` | tdd-plan 和 execute 是独立 Skill |
+| 测试流水线 | `workflow-test-fix` | 内部完成 gen→cycle |
+| 代码审查 | `review-cycle` | 内部完成 review→fix |
## Execution Model
@@ -157,153 +154,119 @@ function selectWorkflow(analysis) {
return buildCommandChain(selected, analysis);
}
-// Build command chain (port-based matching with Minimum Execution Units)
+// Build command chain (Skill-based composition)
function buildCommandChain(workflow, analysis) {
const chains = {
// Level 2 - Lightweight
'rapid': [
- // Unit: Quick Implementation【lite-plan → lite-execute】
- { cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl' },
- { cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'quick-impl' },
- // Unit: Test Validation【test-fix-gen → test-cycle-execute】
+ { cmd: 'workflow-lite-plan', args: `"${analysis.goal}"` },
...(analysis.constraints?.includes('skip-tests') ? [] : [
- { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
- { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
+ { cmd: 'workflow-test-fix', args: '' }
])
],
// Level 2 Bridge - Lightweight to Issue Workflow
'rapid-to-issue': [
- // Unit: Quick Implementation【lite-plan → convert-to-plan】
- { cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl-to-issue' },
- { cmd: '/issue:convert-to-plan', args: '--latest-lite-plan -y', unit: 'quick-impl-to-issue' },
- // Auto-continue to issue workflow
- { cmd: '/issue:queue', args: '' },
- { cmd: '/issue:execute', args: '--queue auto' }
+ { cmd: 'workflow-lite-plan', args: `"${analysis.goal}" --plan-only` },
+ { cmd: 'issue:convert-to-plan', args: '--latest-lite-plan -y' },
+ { cmd: 'issue:queue', args: '' },
+ { cmd: 'issue:execute', args: '--queue auto' }
],
'bugfix.standard': [
- // Unit: Bug Fix【lite-plan → lite-execute】
- { cmd: '/workflow:lite-plan', args: `--bugfix "${analysis.goal}"`, unit: 'bug-fix' },
- { cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'bug-fix' },
- // Unit: Test Validation【test-fix-gen → test-cycle-execute】
+ { cmd: 'workflow-lite-plan', args: `--bugfix "${analysis.goal}"` },
...(analysis.constraints?.includes('skip-tests') ? [] : [
- { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
- { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
+ { cmd: 'workflow-test-fix', args: '' }
])
],
'bugfix.hotfix': [
- { cmd: '/workflow:lite-plan', args: `--hotfix "${analysis.goal}"` }
+ { cmd: 'workflow-lite-plan', args: `--hotfix "${analysis.goal}"` }
],
'multi-cli-plan': [
- // Unit: Multi-CLI Planning【multi-cli-plan → lite-execute】
- { cmd: '/workflow:multi-cli-plan', args: `"${analysis.goal}"`, unit: 'multi-cli' },
- { cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'multi-cli' },
- // Unit: Test Validation【test-fix-gen → test-cycle-execute】
+ { cmd: 'workflow-multi-cli-plan', args: `"${analysis.goal}"` },
...(analysis.constraints?.includes('skip-tests') ? [] : [
- { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
- { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
+ { cmd: 'workflow-test-fix', args: '' }
])
],
'docs': [
- // Unit: Quick Implementation【lite-plan → lite-execute】
- { cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl' },
- { cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'quick-impl' }
+ { cmd: 'workflow-lite-plan', args: `"${analysis.goal}"` }
],
// With-File workflows (documented exploration with multi-CLI collaboration)
'brainstorm-with-file': [
- { cmd: '/workflow:brainstorm-with-file', args: `"${analysis.goal}"` }
+ { cmd: 'workflow:brainstorm-with-file', args: `"${analysis.goal}"` }
// Note: Has built-in post-completion options (create plan, create issue, deep analysis)
],
// Brainstorm-to-Issue workflow (bridge from brainstorm to issue execution)
'brainstorm-to-issue': [
// Note: Assumes brainstorm session already exists, or run brainstorm first
- { cmd: '/issue:from-brainstorm', args: `SESSION="${extractBrainstormSession(analysis)}" --auto` },
- { cmd: '/issue:queue', args: '' },
- { cmd: '/issue:execute', args: '--queue auto' }
+ { cmd: 'issue:from-brainstorm', args: `SESSION="${extractBrainstormSession(analysis)}" --auto` },
+ { cmd: 'issue:queue', args: '' },
+ { cmd: 'issue:execute', args: '--queue auto' }
],
'debug-with-file': [
- { cmd: '/workflow:debug-with-file', args: `"${analysis.goal}"` }
+ { cmd: 'workflow:debug-with-file', args: `"${analysis.goal}"` }
// Note: Self-contained with hypothesis-driven iteration and Gemini validation
],
'analyze-with-file': [
- { cmd: '/workflow:analyze-with-file', args: `"${analysis.goal}"` }
+ { cmd: 'workflow:analyze-with-file', args: `"${analysis.goal}"` }
// Note: Self-contained with multi-round discussion and CLI exploration
],
// Level 3 - Standard
'coupled': [
- // Unit: Verified Planning【plan → plan-verify】
- { cmd: '/workflow:plan', args: `"${analysis.goal}"`, unit: 'verified-planning' },
- { cmd: '/workflow:plan-verify', args: '', unit: 'verified-planning' },
- // Execution
- { cmd: '/workflow:execute', args: '' },
- // Unit: Code Review【review-session-cycle → review-cycle-fix】
- { cmd: '/workflow:review-session-cycle', args: '', unit: 'code-review' },
- { cmd: '/workflow:review-cycle-fix', args: '', unit: 'code-review' },
- // Unit: Test Validation【test-fix-gen → test-cycle-execute】
+ { cmd: 'workflow-plan', args: `"${analysis.goal}"` },
+ { cmd: 'workflow-execute', args: '' },
+ { cmd: 'review-cycle', args: '' },
...(analysis.constraints?.includes('skip-tests') ? [] : [
- { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
- { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
+ { cmd: 'workflow-test-fix', args: '' }
])
],
'tdd': [
- // Unit: TDD Planning + Execution【tdd-plan → execute】
- { cmd: '/workflow:tdd-plan', args: `"${analysis.goal}"`, unit: 'tdd-planning' },
- { cmd: '/workflow:execute', args: '', unit: 'tdd-planning' },
- // TDD Verification
- { cmd: '/workflow:tdd-verify', args: '' }
+ { cmd: 'workflow-tdd', args: `"${analysis.goal}"` },
+ { cmd: 'workflow-execute', args: '' }
],
'test-fix-gen': [
- // Unit: Test Validation【test-fix-gen → test-cycle-execute】
- { cmd: '/workflow:test-fix-gen', args: `"${analysis.goal}"`, unit: 'test-validation' },
- { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
+ { cmd: 'workflow-test-fix', args: `"${analysis.goal}"` }
],
'review-cycle-fix': [
- // Unit: Code Review【review-session-cycle → review-cycle-fix】
- { cmd: '/workflow:review-session-cycle', args: '', unit: 'code-review' },
- { cmd: '/workflow:review-cycle-fix', args: '', unit: 'code-review' },
- // Unit: Test Validation【test-fix-gen → test-cycle-execute】
- { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
- { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
+ { cmd: 'review-cycle', args: '' },
+ ...(analysis.constraints?.includes('skip-tests') ? [] : [
+ { cmd: 'workflow-test-fix', args: '' }
+ ])
],
'ui': [
- { cmd: '/workflow:ui-design:explore-auto', args: `"${analysis.goal}"` },
- // Unit: Planning + Execution【plan → execute】
- { cmd: '/workflow:plan', args: '', unit: 'plan-execute' },
- { cmd: '/workflow:execute', args: '', unit: 'plan-execute' }
+ { cmd: 'workflow:ui-design:explore-auto', args: `"${analysis.goal}"` },
+ { cmd: 'workflow-plan', args: '' },
+ { cmd: 'workflow-execute', args: '' }
],
- // Level 4 - Brainstorm
+ // Level 4 - Full
'full': [
- { cmd: '/brainstorm', args: `"${analysis.goal}"` },
- // Unit: Verified Planning【plan → plan-verify】
- { cmd: '/workflow:plan', args: '', unit: 'verified-planning' },
- { cmd: '/workflow:plan-verify', args: '', unit: 'verified-planning' },
- // Execution
- { cmd: '/workflow:execute', args: '' },
- // Unit: Test Validation【test-fix-gen → test-cycle-execute】
- { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
- { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
+ { cmd: 'brainstorm', args: `"${analysis.goal}"` },
+ { cmd: 'workflow-plan', args: '' },
+ { cmd: 'workflow-execute', args: '' },
+ ...(analysis.constraints?.includes('skip-tests') ? [] : [
+ { cmd: 'workflow-test-fix', args: '' }
+ ])
],
// Issue Workflow
'issue': [
- { cmd: '/issue:discover', args: '' },
- { cmd: '/issue:plan', args: '--all-pending' },
- { cmd: '/issue:queue', args: '' },
- { cmd: '/issue:execute', args: '' }
+ { cmd: 'issue:discover', args: '' },
+ { cmd: 'issue:plan', args: '--all-pending' },
+ { cmd: 'issue:queue', args: '' },
+ { cmd: 'issue:execute', args: '' }
]
};
@@ -311,7 +274,7 @@ function buildCommandChain(workflow, analysis) {
}
```
-**Output**: `Level [X] - [flow] | Pipeline: [...] | Commands: [1. /cmd1 2. /cmd2 ...]`
+**Output**: `Level [X] - [flow] | Pipeline: [...] | Commands: [1. cmd1 2. cmd2 ...]`
---
@@ -377,7 +340,7 @@ function setupTodoTracking(chain, workflow, analysis) {
```
**Output**:
-- TODO: `-> CCW:rapid: [1/3] /workflow:lite-plan | CCW:rapid: [2/3] /workflow:lite-execute | ...`
+- TODO: `-> CCW:rapid: [1/2] workflow-lite-plan | CCW:rapid: [2/2] workflow-test-fix | ...`
- Status File: `.workflow/.ccw/{session_id}/status.json`
---
@@ -397,8 +360,8 @@ async function executeCommandChain(chain, workflow, trackingState) {
state.updated_at = new Date().toISOString();
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
- const fullCommand = assembleCommand(chain[i], previousResult);
- const result = await Skill({ skill: fullCommand });
+ const assembled = assembleCommand(chain[i], previousResult);
+ const result = await Skill(assembled);
previousResult = { ...result, success: true };
@@ -442,15 +405,13 @@ async function executeCommandChain(chain, workflow, trackingState) {
return { success: true, completed: chain.length, sessionId };
}
-// Assemble full command with session/plan parameters
+// Assemble Skill call with session/plan parameters
function assembleCommand(step, previousResult) {
- let command = step.cmd;
- if (step.args) {
- command += ` ${step.args}`;
- } else if (previousResult?.session_id) {
- command += ` --session="${previousResult.session_id}"`;
+ let args = step.args || '';
+ if (!args && previousResult?.session_id) {
+ args = `--session="${previousResult.session_id}"`;
}
- return command;
+ return { skill: step.cmd, args };
}
// Update TODO: mark current as complete, next as in-progress
@@ -498,7 +459,7 @@ Phase 1: Analyze Intent
Phase 2: Select Workflow & Build Chain
|-- Map task_type -> Level (1/2/3/4/Issue)
|-- Select flow based on complexity
- +-- Build command chain (port-based)
+ +-- Build command chain (Skill-based)
|
Phase 3: User Confirmation (optional)
|-- Show pipeline visualization
@@ -511,7 +472,7 @@ Phase 4: Setup TODO Tracking & Status File
Phase 5: Execute Command Chain
|-- For each command:
| |-- Update status.json (current=running)
- | |-- Assemble full command
+ | |-- Assemble Skill call
| |-- Execute via Skill
| |-- Update status.json (current=completed, next=running)
| |-- Update TODO status
@@ -521,22 +482,20 @@ Phase 5: Execute Command Chain
---
-## Pipeline Examples (with Minimum Execution Units)
+## Pipeline Examples
-**Note**: `【 】` marks Minimum Execution Units - commands execute together as atomic groups.
-
-| Input | Type | Level | Pipeline (with Units) |
-|-------|------|-------|-----------------------|
-| "Add API endpoint" | feature (low) | 2 |【lite-plan → lite-execute】→【test-fix-gen → test-cycle-execute】|
-| "Fix login timeout" | bugfix | 2 |【lite-plan → lite-execute】→【test-fix-gen → test-cycle-execute】|
-| "Use issue workflow" | issue-transition | 2.5 |【lite-plan → convert-to-plan】→ queue → execute |
-| "头脑风暴: 通知系统重构" | brainstorm | 4 | brainstorm-with-file → (built-in post-completion) |
-| "从头脑风暴创建 issue" | brainstorm-to-issue | 4 | from-brainstorm → queue → execute |
-| "深度调试 WebSocket 连接断开" | debug-file | 3 | debug-with-file → (hypothesis iteration) |
-| "协作分析: 认证架构优化" | analyze-file | 3 | analyze-with-file → (multi-round discussion) |
-| "OAuth2 system" | feature (high) | 3 |【plan → plan-verify】→ execute →【review-session-cycle → review-cycle-fix】→【test-fix-gen → test-cycle-execute】|
-| "Implement with TDD" | tdd | 3 |【tdd-plan → execute】→ tdd-verify |
-| "Uncertain: real-time arch" | exploration | 4 | brainstorm →【plan → plan-verify】→ execute →【test-fix-gen → test-cycle-execute】|
+| Input | Type | Level | Pipeline |
+|-------|------|-------|----------|
+| "Add API endpoint" | feature (low) | 2 | workflow-lite-plan → workflow-test-fix |
+| "Fix login timeout" | bugfix | 2 | workflow-lite-plan → workflow-test-fix |
+| "Use issue workflow" | issue-transition | 2.5 | workflow-lite-plan(plan-only) → convert-to-plan → queue → execute |
+| "头脑风暴: 通知系统重构" | brainstorm | 4 | workflow:brainstorm-with-file |
+| "从头脑风暴创建 issue" | brainstorm-to-issue | 4 | issue:from-brainstorm → issue:queue → issue:execute |
+| "深度调试 WebSocket" | debug-file | 3 | workflow:debug-with-file |
+| "协作分析: 认证架构优化" | analyze-file | 3 | workflow:analyze-with-file |
+| "OAuth2 system" | feature (high) | 3 | workflow-plan → workflow-execute → review-cycle → workflow-test-fix |
+| "Implement with TDD" | tdd | 3 | workflow-tdd → workflow-execute |
+| "Uncertain: real-time" | exploration | 4 | brainstorm → workflow-plan → workflow-execute → workflow-test-fix |
---
@@ -544,11 +503,11 @@ Phase 5: Execute Command Chain
1. **Main Process Execution** - Use Skill in main process, no external CLI
2. **Intent-Driven** - Auto-select workflow based on task intent
-3. **Port-Based Chaining** - Build command chain using port matching
-4. **Minimum Execution Units** - Commands grouped into atomic units, never split (e.g., lite-plan → lite-execute)
+3. **Skill-Based Chaining** - Build command chain by composing independent Skills
+4. **Self-Contained Skills** - 每个 Skill 内部处理完整流水线,是天然的最小执行单元
5. **Progressive Clarification** - Low clarity triggers clarification phase
6. **TODO Tracking** - Use CCW prefix to isolate workflow todos
-7. **Unit-Aware Error Handling** - Retry/skip/abort affects whole unit, not individual commands
+7. **Error Handling** - Retry/skip/abort at Skill level
8. **User Control** - Optional user confirmation at each phase
---
@@ -560,18 +519,16 @@ Phase 5: Execute Command Chain
**1. TodoWrite-Based Tracking** (UI Display): All execution state tracked via TodoWrite with `CCW:` prefix.
```javascript
-// Initial state
+// Initial state (rapid workflow: 2 steps)
todos = [
- { content: "CCW:rapid: [1/3] /workflow:lite-plan", status: "in_progress" },
- { content: "CCW:rapid: [2/3] /workflow:lite-execute", status: "pending" },
- { content: "CCW:rapid: [3/3] /workflow:test-cycle-execute", status: "pending" }
+ { content: "CCW:rapid: [1/2] workflow-lite-plan", status: "in_progress" },
+ { content: "CCW:rapid: [2/2] workflow-test-fix", status: "pending" }
];
-// After command 1 completes
+// After step 1 completes
todos = [
- { content: "CCW:rapid: [1/3] /workflow:lite-plan", status: "completed" },
- { content: "CCW:rapid: [2/3] /workflow:lite-execute", status: "in_progress" },
- { content: "CCW:rapid: [3/3] /workflow:test-cycle-execute", status: "pending" }
+ { content: "CCW:rapid: [1/2] workflow-lite-plan", status: "completed" },
+ { content: "CCW:rapid: [2/2] workflow-test-fix", status: "in_progress" }
];
```
@@ -597,18 +554,13 @@ todos = [
"command_chain": [
{
"index": 0,
- "command": "/workflow:lite-plan",
+ "command": "workflow-lite-plan",
"status": "completed"
},
{
"index": 1,
- "command": "/workflow:lite-execute",
+ "command": "workflow-test-fix",
"status": "running"
- },
- {
- "index": 2,
- "command": "/workflow:test-cycle-execute",
- "status": "pending"
}
],
"current_index": 1
diff --git a/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx b/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx
index b3131a45..dfac1f32 100644
--- a/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx
+++ b/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx
@@ -704,7 +704,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
const isLastOdd = currentSession.tasks!.length % 2 === 1 && index === currentSession.tasks!.length - 1;
return (
void;
/** Installation target: Claude or Codex */
target?: 'claude' | 'codex';
+ /** Scopes where CCW MCP is currently installed */
+ installedScopes?: ('global' | 'project')[];
+ /** Callback to uninstall from a specific scope */
+ onUninstallScope?: (scope: 'global' | 'project') => void;
+ /** Callback to install to an additional scope */
+ onInstallToScope?: (scope: 'global' | 'project') => void;
}
// ========== Constants ==========
@@ -115,6 +122,9 @@ export function CcwToolsMcpCard({
onUpdateConfig,
onInstall,
target = 'claude',
+ installedScopes = [],
+ onUninstallScope,
+ onInstallToScope,
}: CcwToolsMcpCardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
@@ -242,9 +252,26 @@ export function CcwToolsMcpCard({
{formatMessage({ id: 'mcp.ccw.title' })}
-
- {isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
-
+ {isInstalled && installedScopes.length > 0 ? (
+ <>
+ {installedScopes.map((s) => (
+
+ {s === 'global' ? : }
+ {formatMessage({ id: `mcp.ccw.scope.${s}` })}
+
+ ))}
+ {installedScopes.length >= 2 && (
+
+
+ {formatMessage({ id: 'mcp.conflict.badge' })}
+
+ )}
+ >
+ ) : (
+
+ {isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
+
+ )}
{isCodex && (
Codex
@@ -425,7 +452,7 @@ export function CcwToolsMcpCard({
{/* Install/Uninstall Button */}
- {/* Scope Selection - Claude only (Codex is always global) */}
+ {/* Scope Selection - Claude only, only when not installed */}
{!isInstalled && !isCodex && (
@@ -465,6 +492,20 @@ export function CcwToolsMcpCard({
{formatMessage({ id: 'mcp.ccw.codexNote' })}
)}
+
+ {/* Dual-scope conflict warning */}
+ {isInstalled && !isCodex && installedScopes.length >= 2 && (
+
+
+
+
{formatMessage({ id: 'mcp.conflict.title' })}
+
+
+ {formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: 'mcp.scope.global' }) })}
+
+
+ )}
+
{!isInstalled ? (
- ) : (
+ ) : isCodex ? (
+ /* Codex: single uninstall button */
+ ) : (
+ /* Claude: per-scope install/uninstall */
+
+ {/* Install to missing scope */}
+ {installedScopes.length === 1 && onInstallToScope && (
+ {
+ const missingScope = installedScopes.includes('global') ? 'project' : 'global';
+ onInstallToScope(missingScope);
+ }}
+ disabled={isPending}
+ className="w-full"
+ >
+ {installedScopes.includes('global')
+ ? formatMessage({ id: 'mcp.ccw.scope.installToProject' })
+ : formatMessage({ id: 'mcp.ccw.scope.installToGlobal' })
+ }
+
+ )}
+
+ {/* Per-scope uninstall buttons */}
+ {onUninstallScope && installedScopes.map((s) => (
+ {
+ if (confirm(formatMessage({ id: 'mcp.ccw.actions.uninstallScopeConfirm' }, { scope: formatMessage({ id: `mcp.ccw.scope.${s}` }) }))) {
+ onUninstallScope(s);
+ }
+ }}
+ disabled={isPending}
+ className="w-full"
+ >
+ {s === 'global'
+ ? formatMessage({ id: 'mcp.ccw.scope.uninstallGlobal' })
+ : formatMessage({ id: 'mcp.ccw.scope.uninstallProject' })
+ }
+
+ ))}
+
+ {/* Fallback: full uninstall if no scope info */}
+ {(!onUninstallScope || installedScopes.length === 0) && (
+
+ {isPending
+ ? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
+ : formatMessage({ id: 'mcp.ccw.actions.uninstall' })
+ }
+
+ )}
+
)}
diff --git a/ccw/frontend/src/components/settings/PlatformConfigCards.tsx b/ccw/frontend/src/components/settings/PlatformConfigCards.tsx
index 5291c534..1a1bb317 100644
--- a/ccw/frontend/src/components/settings/PlatformConfigCards.tsx
+++ b/ccw/frontend/src/components/settings/PlatformConfigCards.tsx
@@ -16,6 +16,10 @@ import {
TestTube,
Eye,
EyeOff,
+ MessageSquare,
+ Bell,
+ Users,
+ Mail,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -28,6 +32,10 @@ import type {
DiscordConfig,
TelegramConfig,
WebhookConfig,
+ FeishuConfig,
+ DingTalkConfig,
+ WeComConfig,
+ EmailConfig,
} from '@/types/remote-notification';
import { PLATFORM_INFO } from '@/types/remote-notification';
@@ -38,11 +46,11 @@ interface PlatformConfigCardsProps {
onToggleExpand: (platform: NotificationPlatform | null) => void;
onUpdateConfig: (
platform: NotificationPlatform,
- updates: Partial
+ updates: Partial
) => void;
onTest: (
platform: NotificationPlatform,
- config: DiscordConfig | TelegramConfig | WebhookConfig
+ config: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
) => void;
onSave: () => void;
saving: boolean;
@@ -60,7 +68,7 @@ export function PlatformConfigCards({
}: PlatformConfigCardsProps) {
const { formatMessage } = useIntl();
- const platforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook'];
+ const platforms: NotificationPlatform[] = ['discord', 'telegram', 'feishu', 'dingtalk', 'wecom', 'email', 'webhook'];
const getPlatformIcon = (platform: NotificationPlatform) => {
switch (platform) {
@@ -68,6 +76,14 @@ export function PlatformConfigCards({
return ;
case 'telegram':
return ;
+ case 'feishu':
+ return ;
+ case 'dingtalk':
+ return ;
+ case 'wecom':
+ return ;
+ case 'email':
+ return ;
case 'webhook':
return ;
}
@@ -75,12 +91,20 @@ export function PlatformConfigCards({
const getPlatformConfig = (
platform: NotificationPlatform
- ): DiscordConfig | TelegramConfig | WebhookConfig => {
+ ): DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig => {
switch (platform) {
case 'discord':
return config.platforms.discord || { enabled: false, webhookUrl: '' };
case 'telegram':
return config.platforms.telegram || { enabled: false, botToken: '', chatId: '' };
+ case 'feishu':
+ return config.platforms.feishu || { enabled: false, webhookUrl: '' };
+ case 'dingtalk':
+ return config.platforms.dingtalk || { enabled: false, webhookUrl: '' };
+ case 'wecom':
+ return config.platforms.wecom || { enabled: false, webhookUrl: '' };
+ case 'email':
+ return config.platforms.email || { enabled: false, host: '', port: 587, username: '', password: '', from: '', to: [] };
case 'webhook':
return config.platforms.webhook || { enabled: false, url: '', method: 'POST' };
}
@@ -93,6 +117,15 @@ export function PlatformConfigCards({
return !!(platformConfig as DiscordConfig).webhookUrl;
case 'telegram':
return !!(platformConfig as TelegramConfig).botToken && !!(platformConfig as TelegramConfig).chatId;
+ case 'feishu':
+ return !!(platformConfig as FeishuConfig).webhookUrl;
+ case 'dingtalk':
+ return !!(platformConfig as DingTalkConfig).webhookUrl;
+ case 'wecom':
+ return !!(platformConfig as WeComConfig).webhookUrl;
+ case 'email':
+ const emailConfig = platformConfig as EmailConfig;
+ return !!(emailConfig.host && emailConfig.username && emailConfig.password && emailConfig.from && emailConfig.to?.length > 0);
case 'webhook':
return !!(platformConfig as WebhookConfig).url;
}
@@ -176,6 +209,30 @@ export function PlatformConfigCards({
onUpdate={(updates) => onUpdateConfig('telegram', updates)}
/>
)}
+ {platform === 'feishu' && (
+ onUpdateConfig('feishu', updates)}
+ />
+ )}
+ {platform === 'dingtalk' && (
+ onUpdateConfig('dingtalk', updates)}
+ />
+ )}
+ {platform === 'wecom' && (
+ onUpdateConfig('wecom', updates)}
+ />
+ )}
+ {platform === 'email' && (
+ onUpdateConfig('email', updates)}
+ />
+ )}
{platform === 'webhook' && (
) => void;
+}) {
+ const { formatMessage } = useIntl();
+ const [showUrl, setShowUrl] = useState(false);
+
+ return (
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.feishu.webhookUrl' })}
+
+
+ onUpdate({ webhookUrl: e.target.value })}
+ placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
+ className="flex-1"
+ />
+ setShowUrl(!showUrl)}
+ >
+ {showUrl ? : }
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.feishu.webhookUrlHint' })}
+
+
+
+ onUpdate({ useCard: e.target.checked })}
+ className="rounded border-border"
+ />
+
+ {formatMessage({ id: 'settings.remoteNotifications.feishu.useCard' })}
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.feishu.useCardHint' })}
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.feishu.title' })}
+
+ onUpdate({ title: e.target.value })}
+ placeholder="CCW Notification"
+ className="mt-1"
+ />
+
+
+ );
+}
+
+// ========== DingTalk Config Form ==========
+
+function DingTalkConfigForm({
+ config,
+ onUpdate,
+}: {
+ config: DingTalkConfig;
+ onUpdate: (updates: Partial) => void;
+}) {
+ const { formatMessage } = useIntl();
+ const [showUrl, setShowUrl] = useState(false);
+
+ return (
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.dingtalk.webhookUrl' })}
+
+
+ onUpdate({ webhookUrl: e.target.value })}
+ placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
+ className="flex-1"
+ />
+ setShowUrl(!showUrl)}
+ >
+ {showUrl ? : }
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.dingtalk.webhookUrlHint' })}
+
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.dingtalk.keywords' })}
+
+
onUpdate({ keywords: e.target.value.split(',').map(k => k.trim()).filter(Boolean) })}
+ placeholder="keyword1, keyword2"
+ className="mt-1"
+ />
+
+ {formatMessage({ id: 'settings.remoteNotifications.dingtalk.keywordsHint' })}
+
+
+
+ );
+}
+
+// ========== WeCom Config Form ==========
+
+function WeComConfigForm({
+ config,
+ onUpdate,
+}: {
+ config: WeComConfig;
+ onUpdate: (updates: Partial) => void;
+}) {
+ const { formatMessage } = useIntl();
+ const [showUrl, setShowUrl] = useState(false);
+
+ return (
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.wecom.webhookUrl' })}
+
+
+ onUpdate({ webhookUrl: e.target.value })}
+ placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
+ className="flex-1"
+ />
+ setShowUrl(!showUrl)}
+ >
+ {showUrl ? : }
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.wecom.webhookUrlHint' })}
+
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.wecom.mentionedList' })}
+
+
onUpdate({ mentionedList: e.target.value.split(',').map(m => m.trim()).filter(Boolean) })}
+ placeholder="userid1, userid2, @all"
+ className="mt-1"
+ />
+
+ {formatMessage({ id: 'settings.remoteNotifications.wecom.mentionedListHint' })}
+
+
+
+ );
+}
+
+// ========== Email Config Form ==========
+
+function EmailConfigForm({
+ config,
+ onUpdate,
+}: {
+ config: EmailConfig;
+ onUpdate: (updates: Partial) => void;
+}) {
+ const { formatMessage } = useIntl();
+ const [showPassword, setShowPassword] = useState(false);
+
+ return (
+
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.host' })}
+
+
onUpdate({ host: e.target.value })}
+ placeholder="smtp.gmail.com"
+ className="mt-1"
+ />
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.hostHint' })}
+
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.port' })}
+
+ onUpdate({ port: parseInt(e.target.value, 10) || 587 })}
+ placeholder="587"
+ className="mt-1"
+ />
+
+
+
+ onUpdate({ secure: e.target.checked })}
+ className="rounded border-border"
+ />
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.secure' })}
+
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.username' })}
+
+ onUpdate({ username: e.target.value })}
+ placeholder="your-email@gmail.com"
+ className="mt-1"
+ />
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.password' })}
+
+
+ onUpdate({ password: e.target.value })}
+ placeholder="********"
+ className="flex-1"
+ />
+ setShowPassword(!showPassword)}
+ >
+ {showPassword ? : }
+
+
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.from' })}
+
+ onUpdate({ from: e.target.value })}
+ placeholder="noreply@example.com"
+ className="mt-1"
+ />
+
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.to' })}
+
+
onUpdate({ to: e.target.value.split(',').map(t => t.trim()).filter(Boolean) })}
+ placeholder="user1@example.com, user2@example.com"
+ className="mt-1"
+ />
+
+ {formatMessage({ id: 'settings.remoteNotifications.email.toHint' })}
+
+
+
+ );
+}
+
export default PlatformConfigCards;
diff --git a/ccw/frontend/src/components/settings/RemoteNotificationSection.tsx b/ccw/frontend/src/components/settings/RemoteNotificationSection.tsx
index ce966afa..27b5782d 100644
--- a/ccw/frontend/src/components/settings/RemoteNotificationSection.tsx
+++ b/ccw/frontend/src/components/settings/RemoteNotificationSection.tsx
@@ -11,15 +11,13 @@ import {
RefreshCw,
Check,
X,
+ Save,
ChevronDown,
ChevronUp,
- TestTube,
- Save,
- AlertTriangle,
+ Plus,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
-import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
@@ -30,6 +28,10 @@ import type {
DiscordConfig,
TelegramConfig,
WebhookConfig,
+ FeishuConfig,
+ DingTalkConfig,
+ WeComConfig,
+ EmailConfig,
} from '@/types/remote-notification';
import { PLATFORM_INFO, EVENT_INFO, getDefaultConfig } from '@/types/remote-notification';
import { PlatformConfigCards } from './PlatformConfigCards';
@@ -45,6 +47,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(null);
const [expandedPlatform, setExpandedPlatform] = useState(null);
+ const [expandedEvent, setExpandedEvent] = useState(null);
// Load configuration
const loadConfig = useCallback(async () => {
@@ -97,7 +100,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
// Test platform
const testPlatform = useCallback(async (
platform: NotificationPlatform,
- platformConfig: DiscordConfig | TelegramConfig | WebhookConfig
+ platformConfig: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
) => {
setTesting(platform);
try {
@@ -136,7 +139,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
// Update platform config
const updatePlatformConfig = (
platform: NotificationPlatform,
- updates: Partial
+ updates: Partial
) => {
if (!config) return;
const newConfig = {
@@ -160,6 +163,19 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
setConfig({ ...config, events: newEvents });
};
+ // Toggle platform for event
+ const toggleEventPlatform = (eventIndex: number, platform: NotificationPlatform) => {
+ if (!config) return;
+ const eventConfig = config.events[eventIndex];
+ const platforms = eventConfig.platforms.includes(platform)
+ ? eventConfig.platforms.filter((p) => p !== platform)
+ : [...eventConfig.platforms, platform];
+ updateEventConfig(eventIndex, { platforms });
+ };
+
+ // All available platforms
+ const allPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'feishu', 'dingtalk', 'wecom', 'email', 'webhook'];
+
// Reset to defaults
const resetConfig = async () => {
if (!confirm(formatMessage({ id: 'settings.remoteNotifications.resetConfirm' }))) {
@@ -266,51 +282,107 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
{config.events.map((eventConfig, index) => {
const info = EVENT_INFO[eventConfig.event];
+ const isExpanded = expandedEvent === index;
return (
-
-
-
{info.icon}
+ {/* Event Header */}
+
setExpandedEvent(isExpanded ? null : index)}
+ >
+
+
+ {info.icon}
+
+
+
{info.name}
+
{info.description}
+
-
-
{info.name}
-
{info.description}
-
-
-
- {/* Platform badges */}
-
- {eventConfig.platforms.map((platform) => (
-
- {PLATFORM_INFO[platform].name}
-
- ))}
- {eventConfig.platforms.length === 0 && (
-
- {formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })}
-
- )}
-
- {/* Toggle */}
-
updateEventConfig(index, { enabled: !eventConfig.enabled })}
- >
- {eventConfig.enabled ? (
-
+
+ {/* Platform badges */}
+
+ {eventConfig.platforms.slice(0, 3).map((platform) => (
+
+ {PLATFORM_INFO[platform].name}
+
+ ))}
+ {eventConfig.platforms.length > 3 && (
+
+ +{eventConfig.platforms.length - 3}
+
+ )}
+ {eventConfig.platforms.length === 0 && (
+
+ {formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })}
+
+ )}
+
+ {/* Toggle */}
+
{
+ e.stopPropagation();
+ updateEventConfig(index, { enabled: !eventConfig.enabled });
+ }}
+ >
+ {eventConfig.enabled ? (
+
+ ) : (
+
+ )}
+
+ {/* Expand icon */}
+ {isExpanded ? (
+
) : (
-
+
)}
-
+
+
+ {/* Expanded Content - Platform Selection */}
+ {isExpanded && (
+
+
+ {formatMessage({ id: 'settings.remoteNotifications.selectPlatforms' })}
+
+
+ {allPlatforms.map((platform) => {
+ const isSelected = eventConfig.platforms.includes(platform);
+ const platformInfo = PLATFORM_INFO[platform];
+ const platformConfig = config.platforms[platform];
+ const isConfigured = platformConfig?.enabled;
+ return (
+
toggleEventPlatform(index, platform)}
+ >
+ {isSelected && }
+ {platformInfo.name}
+ {!isConfigured && !isSelected && (
+
+ )}
+
+ );
+ })}
+
+
+ )}
);
})}
diff --git a/ccw/frontend/src/components/terminal-dashboard/ArtifactTag.tsx b/ccw/frontend/src/components/terminal-dashboard/ArtifactTag.tsx
new file mode 100644
index 00000000..c43e6dd1
--- /dev/null
+++ b/ccw/frontend/src/components/terminal-dashboard/ArtifactTag.tsx
@@ -0,0 +1,78 @@
+// ========================================
+// ArtifactTag Component
+// ========================================
+// Colored, clickable tag for detected CCW artifacts in terminal output.
+
+import { useIntl } from 'react-intl';
+import { cn } from '@/lib/utils';
+import { badgeVariants } from '@/components/ui/Badge';
+import type { ArtifactType } from '@/lib/ccw-artifacts';
+
+export interface ArtifactTagProps {
+ type: ArtifactType;
+ path: string;
+ onClick?: (path: string) => void;
+ className?: string;
+}
+
+function getVariant(type: ArtifactType) {
+ switch (type) {
+ case 'workflow-session':
+ return 'info';
+ case 'lite-session':
+ return 'success';
+ case 'claude-md':
+ return 'review';
+ case 'ccw-config':
+ return 'warning';
+ case 'issue':
+ return 'destructive';
+ default:
+ return 'secondary';
+ }
+}
+
+function getLabelId(type: ArtifactType): string {
+ switch (type) {
+ case 'workflow-session':
+ return 'terminalDashboard.artifacts.types.workflowSession';
+ case 'lite-session':
+ return 'terminalDashboard.artifacts.types.liteSession';
+ case 'claude-md':
+ return 'terminalDashboard.artifacts.types.claudeMd';
+ case 'ccw-config':
+ return 'terminalDashboard.artifacts.types.ccwConfig';
+ case 'issue':
+ return 'terminalDashboard.artifacts.types.issue';
+ }
+}
+
+function basename(p: string): string {
+ const parts = p.split(/[\\/]/g);
+ return parts[parts.length - 1] || p;
+}
+
+export function ArtifactTag({ type, path, onClick, className }: ArtifactTagProps) {
+ const { formatMessage } = useIntl();
+ const label = formatMessage({ id: getLabelId(type) });
+ const display = basename(path);
+
+ return (
+
onClick?.(path)}
+ title={path}
+ >
+ {label}
+ {display}
+
+ );
+}
+
+export default ArtifactTag;
diff --git a/ccw/frontend/src/components/terminal-dashboard/FloatingFileBrowser.tsx b/ccw/frontend/src/components/terminal-dashboard/FloatingFileBrowser.tsx
new file mode 100644
index 00000000..58158ff1
--- /dev/null
+++ b/ccw/frontend/src/components/terminal-dashboard/FloatingFileBrowser.tsx
@@ -0,0 +1,191 @@
+// ========================================
+// FloatingFileBrowser Component
+// ========================================
+// Floating file browser panel for Terminal Dashboard.
+
+import * as React from 'react';
+import { useIntl } from 'react-intl';
+import { Copy, ArrowRightToLine, Loader2, RefreshCw } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { FloatingPanel } from './FloatingPanel';
+import { Button } from '@/components/ui/Button';
+import { TreeView } from '@/components/shared/TreeView';
+import { FilePreview } from '@/components/shared/FilePreview';
+import { useFileExplorer, useFileContent } from '@/hooks/useFileExplorer';
+import type { FileSystemNode } from '@/types/file-explorer';
+
+export interface FloatingFileBrowserProps {
+ isOpen: boolean;
+ onClose: () => void;
+ rootPath: string;
+ onInsertPath?: (path: string) => void;
+ initialSelectedPath?: string | null;
+}
+
+export function FloatingFileBrowser({
+ isOpen,
+ onClose,
+ rootPath,
+ onInsertPath,
+ initialSelectedPath = null,
+}: FloatingFileBrowserProps) {
+ const { formatMessage } = useIntl();
+
+ const {
+ state,
+ rootNodes,
+ isLoading,
+ isFetching,
+ error,
+ refetch,
+ setSelectedFile,
+ toggleExpanded,
+ } = useFileExplorer({
+ rootPath,
+ maxDepth: 6,
+ enabled: isOpen,
+ });
+
+ const selectedPath = state.selectedFile;
+ const { content, isLoading: isContentLoading, error: contentError } = useFileContent(selectedPath, {
+ enabled: isOpen && !!selectedPath,
+ });
+
+ const [copied, setCopied] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!isOpen) return;
+ if (initialSelectedPath) {
+ setSelectedFile(initialSelectedPath);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen, initialSelectedPath]);
+
+ const handleNodeClick = (node: FileSystemNode) => {
+ if (node.type === 'file') {
+ setSelectedFile(node.path);
+ }
+ };
+
+ const handleCopyPath = async () => {
+ if (!selectedPath) return;
+ try {
+ await navigator.clipboard.writeText(selectedPath);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1200);
+ } catch (err) {
+ console.error('[FloatingFileBrowser] copy path failed:', err);
+ }
+ };
+
+ const handleInsert = () => {
+ if (!selectedPath) return;
+ onInsertPath?.(selectedPath);
+ };
+
+ return (
+
+
+ {/* Toolbar */}
+
+
+
+ {selectedPath
+ ? formatMessage({ id: 'terminalDashboard.fileBrowser.selected' })
+ : formatMessage({ id: 'terminalDashboard.fileBrowser.noSelection' })}
+
+
+ {selectedPath ?? rootPath}
+
+
+
+
void refetch()}
+ disabled={!isOpen || isFetching}
+ title={formatMessage({ id: 'common.actions.refresh' })}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {/* Tree */}
+
+ {isLoading ? (
+
+
+
+ {formatMessage({ id: 'terminalDashboard.fileBrowser.loading' })}
+
+
+ ) : error ? (
+
+ {formatMessage({ id: 'terminalDashboard.fileBrowser.loadFailed' })}
+
+ ) : (
+
+ )}
+
+
+ {/* Preview */}
+
+
+
+
+
+
+ );
+}
+
+export default FloatingFileBrowser;
diff --git a/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx b/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx
index 11d1cc85..7f1c4bc5 100644
--- a/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx
+++ b/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx
@@ -6,7 +6,7 @@
// XTerm instance in ref, FitAddon, ResizeObserver, batched PTY input (30ms),
// output chunk streaming from cliSessionStore.
-import { useEffect, useRef, useCallback } from 'react';
+import { useEffect, useRef, useCallback, useState } from 'react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { useCliSessionStore } from '@/stores/cliSessionStore';
@@ -18,6 +18,8 @@ import {
resizeCliSession,
} from '@/lib/api';
import { cn } from '@/lib/utils';
+import { detectCcArtifacts, type CcArtifact } from '@/lib/ccw-artifacts';
+import { ArtifactTag } from './ArtifactTag';
// ========== Types ==========
@@ -26,11 +28,54 @@ interface TerminalInstanceProps {
sessionId: string;
/** Additional CSS classes */
className?: string;
+ /** Optional callback to reveal a detected artifact path (e.g. open file browser) */
+ onRevealPath?: (path: string) => void;
}
// ========== Component ==========
-export function TerminalInstance({ sessionId, className }: TerminalInstanceProps) {
+const ARTIFACT_DEBOUNCE_MS = 250;
+const MAX_ARTIFACT_TAGS = 12;
+
+function isAbsolutePath(p: string): boolean {
+ if (!p) return false;
+ if (p.startsWith('/') || p.startsWith('\\')) return true;
+ if (/^[A-Za-z]:[\\/]/.test(p)) return true;
+ if (p.startsWith('~')) return true;
+ return false;
+}
+
+function joinPath(base: string, relative: string): string {
+ const sep = base.includes('\\') ? '\\' : '/';
+ const b = base.replace(/[\\/]+$/, '');
+ const r = relative.replace(/^[\\/]+/, '');
+ return `${b}${sep}${r}`;
+}
+
+function resolveArtifactPath(path: string, projectPath: string | null): string {
+ if (!path) return path;
+ if (isAbsolutePath(path)) return path;
+ if (!projectPath) return path;
+ return joinPath(projectPath, path);
+}
+
+function mergeArtifacts(prev: CcArtifact[], next: CcArtifact[]): CcArtifact[] {
+ if (next.length === 0) return prev;
+ const map = new Map
();
+ for (const a of prev) map.set(`${a.type}:${a.path}`, a);
+ let changed = false;
+ for (const a of next) {
+ const key = `${a.type}:${a.path}`;
+ if (map.has(key)) continue;
+ map.set(key, a);
+ changed = true;
+ }
+ if (!changed) return prev;
+ const merged = Array.from(map.values());
+ return merged.length > MAX_ARTIFACT_TAGS ? merged.slice(merged.length - MAX_ARTIFACT_TAGS) : merged;
+}
+
+export function TerminalInstance({ sessionId, className, onRevealPath }: TerminalInstanceProps) {
const projectPath = useWorkflowStore(selectProjectPath);
// cliSessionStore selectors
@@ -38,6 +83,8 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
const setBuffer = useCliSessionStore((s) => s.setBuffer);
const clearOutput = useCliSessionStore((s) => s.clearOutput);
+ const [artifacts, setArtifacts] = useState([]);
+
// ========== xterm Refs ==========
const terminalHostRef = useRef(null);
@@ -45,6 +92,10 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
const fitAddonRef = useRef(null);
const lastChunkIndexRef = useRef(0);
+ // Debounced artifact detection
+ const pendingArtifactTextRef = useRef('');
+ const artifactTimerRef = useRef(null);
+
// PTY input batching (30ms, matching TerminalMainArea)
const pendingInputRef = useRef('');
const flushTimerRef = useRef(null);
@@ -56,6 +107,37 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
const projectPathRef = useRef(projectPath);
projectPathRef.current = projectPath;
+ const handleArtifactClick = useCallback((path: string) => {
+ const resolved = resolveArtifactPath(path, projectPathRef.current);
+ navigator.clipboard.writeText(resolved).catch((err) => {
+ console.error('[TerminalInstance] copy artifact path failed:', err);
+ });
+ onRevealPath?.(resolved);
+ }, [onRevealPath]);
+
+ const scheduleArtifactParse = useCallback((text: string) => {
+ if (!text) return;
+ pendingArtifactTextRef.current += text;
+ if (artifactTimerRef.current !== null) return;
+ artifactTimerRef.current = window.setTimeout(() => {
+ artifactTimerRef.current = null;
+ const pending = pendingArtifactTextRef.current;
+ pendingArtifactTextRef.current = '';
+ const detected = detectCcArtifacts(pending);
+ if (detected.length === 0) return;
+ setArtifacts((prev) => mergeArtifacts(prev, detected));
+ }, ARTIFACT_DEBOUNCE_MS);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (artifactTimerRef.current !== null) {
+ window.clearTimeout(artifactTimerRef.current);
+ artifactTimerRef.current = null;
+ }
+ };
+ }, []);
+
// ========== PTY Input Batching ==========
const flushInput = useCallback(async () => {
@@ -139,6 +221,14 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
term.reset();
term.clear();
+ // Reset artifact detection state per session
+ setArtifacts([]);
+ pendingArtifactTextRef.current = '';
+ if (artifactTimerRef.current !== null) {
+ window.clearTimeout(artifactTimerRef.current);
+ artifactTimerRef.current = null;
+ }
+
if (!sessionId) return;
clearOutput(sessionId);
@@ -164,12 +254,18 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
if (start >= chunks.length) return;
const { feedMonitor } = useSessionManagerStore.getState();
+ const newTextParts: string[] = [];
for (let i = start; i < chunks.length; i++) {
term.write(chunks[i].data);
feedMonitor(sessionId, chunks[i].data);
+ newTextParts.push(chunks[i].data);
}
lastChunkIndexRef.current = chunks.length;
- }, [outputChunks, sessionId]);
+
+ if (newTextParts.length > 0) {
+ scheduleArtifactParse(newTextParts.join(''));
+ }
+ }, [outputChunks, sessionId, scheduleArtifactParse]);
// ResizeObserver -> fit + resize backend
useEffect(() => {
@@ -203,9 +299,21 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
// ========== Render ==========
return (
-
+
+ {artifacts.length > 0 && (
+
+ {artifacts.map((a) => (
+
+ ))}
+
+ )}
+
+
);
}
diff --git a/ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx b/ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx
index 7cd867d8..83927307 100644
--- a/ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx
+++ b/ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx
@@ -9,6 +9,7 @@ import { useIntl } from 'react-intl';
import {
SplitSquareHorizontal,
SplitSquareVertical,
+ FolderOpen,
Eraser,
AlertTriangle,
X,
@@ -21,6 +22,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { TerminalInstance } from './TerminalInstance';
+import { FloatingFileBrowser } from './FloatingFileBrowser';
import {
useTerminalGridStore,
selectTerminalGridPanes,
@@ -37,6 +39,8 @@ import {
} from '@/stores/issueQueueIntegrationStore';
import { useCliSessionStore } from '@/stores/cliSessionStore';
import { getAllPaneIds } from '@/lib/layout-utils';
+import { sendCliSessionText } from '@/lib/api';
+import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import type { PaneId } from '@/stores/viewerStore';
import type { TerminalStatus } from '@/types/terminal-dashboard';
@@ -75,6 +79,10 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
const isFocused = focusedPaneId === paneId;
const canClose = getAllPaneIds(layout).length > 1;
+ const projectPath = useWorkflowStore(selectProjectPath);
+ const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false);
+ const [initialFileBrowserPath, setInitialFileBrowserPath] = useState(null);
+
// Session data
const groups = useSessionManagerStore(selectGroups);
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
@@ -146,6 +154,25 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
}
}, [paneId, sessionId, assignSession]);
+ const handleOpenFileBrowser = useCallback(() => {
+ setInitialFileBrowserPath(null);
+ setIsFileBrowserOpen(true);
+ }, []);
+
+ const handleRevealPath = useCallback((path: string) => {
+ setInitialFileBrowserPath(path);
+ setIsFileBrowserOpen(true);
+ }, []);
+
+ const handleInsertPath = useCallback((path: string) => {
+ if (!sessionId) return;
+ sendCliSessionText(
+ sessionId,
+ { text: path, appendNewline: false },
+ projectPath ?? undefined
+ ).catch((err) => console.error('[TerminalPane] insert path failed:', err));
+ }, [sessionId, projectPath]);
+
const handleRestart = useCallback(async () => {
if (!sessionId || isRestarting) return;
setIsRestarting(true);
@@ -291,6 +318,19 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
>
)}
+
+
+
{alertCount > 0 && (
@@ -314,7 +354,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
{/* Terminal content */}
{sessionId ? (
-
+
) : (
@@ -329,6 +369,17 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
)}
+
+
{
+ setIsFileBrowserOpen(false);
+ setInitialFileBrowserPath(null);
+ }}
+ rootPath={projectPath ?? '/'}
+ onInsertPath={sessionId ? handleInsertPath : undefined}
+ initialSelectedPath={initialFileBrowserPath}
+ />
);
}
diff --git a/ccw/frontend/src/hooks/useMcpServers.ts b/ccw/frontend/src/hooks/useMcpServers.ts
index a4e905f2..b8e8826b 100644
--- a/ccw/frontend/src/hooks/useMcpServers.ts
+++ b/ccw/frontend/src/hooks/useMcpServers.ts
@@ -21,6 +21,7 @@ import {
crossCliCopy,
type McpServer,
type McpServersResponse,
+ type McpServerConflict,
type McpProjectConfigType,
type McpTemplate,
type McpTemplateInstallRequest,
@@ -66,6 +67,7 @@ export interface UseMcpServersReturn {
servers: McpServer[];
projectServers: McpServer[];
globalServers: McpServer[];
+ conflicts: McpServerConflict[];
totalCount: number;
enabledCount: number;
isLoading: boolean;
@@ -95,6 +97,7 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers
const projectServers = query.data?.project ?? [];
const globalServers = query.data?.global ?? [];
+ const conflicts = query.data?.conflicts ?? [];
const allServers = scope === 'project' ? projectServers : scope === 'global' ? globalServers : [...projectServers, ...globalServers];
const enabledServers = allServers.filter((s) => s.enabled);
@@ -111,6 +114,7 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers
servers: allServers,
projectServers,
globalServers,
+ conflicts,
totalCount: allServers.length,
enabledCount: enabledServers.length,
isLoading: query.isLoading,
@@ -224,6 +228,7 @@ export function useToggleMcpServer(): UseToggleMcpServerReturn {
return {
project: updateServer(old.project),
global: updateServer(old.global),
+ conflicts: old.conflicts ?? [],
};
});
diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts
index 98d4cda4..35fdff57 100644
--- a/ccw/frontend/src/lib/api.ts
+++ b/ccw/frontend/src/lib/api.ts
@@ -2507,9 +2507,18 @@ export interface McpServer {
scope: 'project' | 'global';
}
+export interface McpServerConflict {
+ name: string;
+ projectServer: McpServer;
+ globalServer: McpServer;
+ /** Runtime effective scope */
+ effectiveScope: 'global' | 'project';
+}
+
export interface McpServersResponse {
project: McpServer[];
global: McpServer[];
+ conflicts: McpServerConflict[];
}
/**
@@ -2618,7 +2627,6 @@ export async function fetchMcpServers(projectPath?: string): Promise
!(name in userServers) && !(name in enterpriseServers))
.map(([name, raw]) => {
const normalized = normalizeServerConfig(raw);
return {
name,
...normalized,
enabled: !disabledSet.has(name),
- scope: 'project',
+ scope: 'project' as const,
};
});
+ // Detect conflicts: same name exists in both project and global
+ const conflicts: McpServerConflict[] = [];
+ for (const ps of project) {
+ const gs = global.find(g => g.name === ps.name);
+ if (gs) {
+ conflicts.push({
+ name: ps.name,
+ projectServer: ps,
+ globalServer: gs,
+ effectiveScope: 'global',
+ });
+ }
+ }
+
return {
project,
global,
+ conflicts,
};
}
@@ -3549,6 +3570,7 @@ export interface CcwMcpConfig {
projectRoot?: string;
allowedDirs?: string;
enableSandbox?: boolean;
+ installedScopes: ('global' | 'project')[];
}
/**
@@ -3605,22 +3627,24 @@ export async function fetchCcwMcpConfig(): Promise {
try {
const config = await fetchMcpConfig();
- // Check if ccw-tools server exists in any config
+ const installedScopes: ('global' | 'project')[] = [];
let ccwServer: any = null;
- // Check global servers
+ // Check global/user servers
if (config.globalServers?.['ccw-tools']) {
+ installedScopes.push('global');
ccwServer = config.globalServers['ccw-tools'];
- }
- // Check user servers
- if (!ccwServer && config.userServers?.['ccw-tools']) {
+ } else if (config.userServers?.['ccw-tools']) {
+ installedScopes.push('global');
ccwServer = config.userServers['ccw-tools'];
}
+
// Check project servers
- if (!ccwServer && config.projects) {
+ if (config.projects) {
for (const proj of Object.values(config.projects)) {
if (proj.mcpServers?.['ccw-tools']) {
- ccwServer = proj.mcpServers['ccw-tools'];
+ installedScopes.push('project');
+ if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools'];
break;
}
}
@@ -3630,6 +3654,7 @@ export async function fetchCcwMcpConfig(): Promise {
return {
isInstalled: false,
enabledTools: [],
+ installedScopes: [],
};
}
@@ -3646,11 +3671,13 @@ export async function fetchCcwMcpConfig(): Promise {
projectRoot: env.CCW_PROJECT_ROOT,
allowedDirs: env.CCW_ALLOWED_DIRS,
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
+ installedScopes,
};
} catch {
return {
isInstalled: false,
enabledTools: [],
+ installedScopes: [],
};
}
}
@@ -3742,6 +3769,27 @@ export async function uninstallCcwMcp(): Promise {
}
}
+/**
+ * Uninstall CCW Tools MCP server from a specific scope
+ */
+export async function uninstallCcwMcpFromScope(
+ scope: 'global' | 'project',
+ projectPath?: string
+): Promise {
+ if (scope === 'global') {
+ await fetchApi('/api/mcp-remove-global-server', {
+ method: 'POST',
+ body: JSON.stringify({ serverName: 'ccw-tools' }),
+ });
+ } else {
+ if (!projectPath) throw new Error('projectPath required for project scope uninstall');
+ await fetchApi('/api/mcp-remove-server', {
+ method: 'POST',
+ body: JSON.stringify({ projectPath, serverName: 'ccw-tools' }),
+ });
+ }
+}
+
// ========== CCW Tools MCP - Codex API ==========
/**
@@ -3753,7 +3801,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise {
const ccwServer = servers.find((s) => s.name === 'ccw-tools');
if (!ccwServer) {
- return { isInstalled: false, enabledTools: [] };
+ return { isInstalled: false, enabledTools: [], installedScopes: [] };
}
const env = ccwServer.env || {};
@@ -3768,9 +3816,10 @@ export async function fetchCcwMcpConfigForCodex(): Promise {
projectRoot: env.CCW_PROJECT_ROOT,
allowedDirs: env.CCW_ALLOWED_DIRS,
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
+ installedScopes: ['global'],
};
} catch {
- return { isInstalled: false, enabledTools: [] };
+ return { isInstalled: false, enabledTools: [], installedScopes: [] };
}
}
@@ -3856,18 +3905,39 @@ export async function updateCcwConfigForCodex(config: {
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchIndexStatus(projectPath?: string): Promise {
- const url = projectPath ? `/api/index/status?path=${encodeURIComponent(projectPath)}` : '/api/index/status';
- return fetchApi(url);
+ const url = projectPath
+ ? `/api/codexlens/workspace-status?path=${encodeURIComponent(projectPath)}`
+ : '/api/codexlens/workspace-status';
+ const resp = await fetchApi<{
+ success: boolean;
+ hasIndex: boolean;
+ fts?: { indexedFiles: number; totalFiles: number };
+ }>(url);
+ return {
+ totalFiles: resp.fts?.totalFiles ?? 0,
+ lastUpdated: new Date().toISOString(),
+ buildTime: 0,
+ status: resp.hasIndex ? 'completed' : 'idle',
+ };
}
/**
* Rebuild index
*/
export async function rebuildIndex(request: IndexRebuildRequest = {}): Promise {
- return fetchApi('/api/index/rebuild', {
+ await fetchApi<{ success: boolean }>('/api/codexlens/init', {
method: 'POST',
- body: JSON.stringify(request),
+ body: JSON.stringify({
+ path: request.paths?.[0],
+ indexType: 'vector',
+ }),
});
+ return {
+ totalFiles: 0,
+ lastUpdated: new Date().toISOString(),
+ buildTime: 0,
+ status: 'building',
+ };
}
// ========== Prompt History API ==========
diff --git a/ccw/frontend/src/lib/ccw-artifacts.test.ts b/ccw/frontend/src/lib/ccw-artifacts.test.ts
new file mode 100644
index 00000000..05c0cfeb
--- /dev/null
+++ b/ccw/frontend/src/lib/ccw-artifacts.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from 'vitest';
+import { detectCcArtifacts } from './ccw-artifacts';
+
+describe('ccw-artifacts', () => {
+ it('returns empty array for empty input', () => {
+ expect(detectCcArtifacts('')).toEqual([]);
+ });
+
+ it('detects workflow session artifacts', () => {
+ const text = 'Created: (.workflow/active/WFS-demo/workflow-session.json)';
+ expect(detectCcArtifacts(text)).toEqual([
+ { type: 'workflow-session', path: '.workflow/active/WFS-demo/workflow-session.json' },
+ ]);
+ });
+
+ it('detects lite session artifacts', () => {
+ const text = 'Plan: .workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json';
+ expect(detectCcArtifacts(text)).toEqual([
+ { type: 'lite-session', path: '.workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json' },
+ ]);
+ });
+
+ it('detects CLAUDE.md artifacts (case-insensitive)', () => {
+ const text = 'Updated: /repo/docs/claude.md and also CLAUDE.md';
+ const res = detectCcArtifacts(text);
+ expect(res).toEqual([
+ { type: 'claude-md', path: '/repo/docs/claude.md' },
+ { type: 'claude-md', path: 'CLAUDE.md' },
+ ]);
+ });
+
+ it('detects CCW config artifacts', () => {
+ const text = 'Config: .ccw/config.toml and ccw.config.yaml';
+ expect(detectCcArtifacts(text)).toEqual([
+ { type: 'ccw-config', path: '.ccw/config.toml' },
+ { type: 'ccw-config', path: 'ccw.config.yaml' },
+ ]);
+ });
+
+ it('detects issue artifacts', () => {
+ const text = 'Queue: .workflow/issues/queues/index.json';
+ expect(detectCcArtifacts(text)).toEqual([
+ { type: 'issue', path: '.workflow/issues/queues/index.json' },
+ ]);
+ });
+
+ it('deduplicates repeated artifacts', () => {
+ const text = '.workflow/issues/issues.jsonl ... .workflow/issues/issues.jsonl';
+ expect(detectCcArtifacts(text)).toEqual([
+ { type: 'issue', path: '.workflow/issues/issues.jsonl' },
+ ]);
+ });
+
+ it('preserves discovery order across types', () => {
+ const text = [
+ 'Issue: .workflow/issues/issues.jsonl',
+ 'Then plan: .workflow/.lite-plan/abc/plan.json',
+ 'Then session: .workflow/active/WFS-x/workflow-session.json',
+ 'Then config: .ccw/config.toml',
+ 'Then CLAUDE: CLAUDE.md',
+ ].join(' | ');
+
+ const res = detectCcArtifacts(text);
+ expect(res.map((a) => a.type)).toEqual([
+ 'issue',
+ 'lite-session',
+ 'workflow-session',
+ 'ccw-config',
+ 'claude-md',
+ ]);
+ });
+});
+
diff --git a/ccw/frontend/src/lib/ccw-artifacts.ts b/ccw/frontend/src/lib/ccw-artifacts.ts
new file mode 100644
index 00000000..7b12ddd9
--- /dev/null
+++ b/ccw/frontend/src/lib/ccw-artifacts.ts
@@ -0,0 +1,91 @@
+// ========================================
+// CCW Artifacts - Types & Detection
+// ========================================
+
+export type ArtifactType =
+ | 'workflow-session'
+ | 'lite-session'
+ | 'claude-md'
+ | 'ccw-config'
+ | 'issue';
+
+export interface CcArtifact {
+ type: ArtifactType;
+ path: string;
+}
+
+const TRAILING_PUNCTUATION = /[)\]}>,.;:!?]+$/g;
+const WRAP_QUOTES = /^['"`]+|['"`]+$/g;
+
+function normalizePath(raw: string): string {
+ return raw.trim().replace(WRAP_QUOTES, '').replace(TRAILING_PUNCTUATION, '');
+}
+
+/**
+ * Patterns for detecting CCW-related artifacts in terminal output.
+ *
+ * Notes:
+ * - Prefer relative paths (e.g., `.workflow/...`) so callers can resolve against a project root.
+ * - Keep patterns conservative to reduce false positives in generic logs.
+ */
+export const ARTIFACT_PATTERNS: Record = {
+ 'workflow-session': [
+ /(?:^|[^\w.])(\.workflow[\\/](?:active|archives)[\\/][^\s"'`]+[\\/]workflow-session\.json)\b/g,
+ ],
+ 'lite-session': [
+ /(?:^|[^\w.])(\.workflow[\\/]\.lite-plan[\\/][^\s"'`]+)\b/g,
+ ],
+ 'claude-md': [
+ /([^\s"'`]*CLAUDE\.md)\b/gi,
+ ],
+ 'ccw-config': [
+ /(?:^|[^\w.])(\.ccw[\\/][^\s"'`]+)\b/g,
+ /(?:^|[^\w.])(ccw\.config\.(?:json|ya?ml|toml))\b/gi,
+ ],
+ issue: [
+ /(?:^|[^\w.])(\.workflow[\\/]issues[\\/][^\s"'`]+)\b/g,
+ ],
+};
+
+/**
+ * Detect CCW artifacts from an arbitrary text blob.
+ *
+ * Returns a de-duplicated list of `{ type, path }` in discovery order.
+ */
+export function detectCcArtifacts(text: string): CcArtifact[] {
+ if (!text) return [];
+
+ const candidates: Array = [];
+
+ for (const type of Object.keys(ARTIFACT_PATTERNS) as ArtifactType[]) {
+ for (const pattern of ARTIFACT_PATTERNS[type]) {
+ pattern.lastIndex = 0;
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(text)) !== null) {
+ const raw = match[1] ?? match[0];
+ const path = normalizePath(raw);
+ if (!path) continue;
+
+ const full = match[0] ?? '';
+ const group = match[1] ?? raw;
+ const rel = full.indexOf(group);
+ const index = (match.index ?? 0) + (rel >= 0 ? rel : 0);
+
+ candidates.push({ type, path, index });
+ }
+ }
+ }
+
+ candidates.sort((a, b) => a.index - b.index);
+
+ const results: CcArtifact[] = [];
+ const seen = new Set();
+ for (const c of candidates) {
+ const key = `${c.type}:${c.path}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ results.push({ type: c.type, path: c.path });
+ }
+
+ return results;
+}
diff --git a/ccw/frontend/src/locales/en/mcp-manager.json b/ccw/frontend/src/locales/en/mcp-manager.json
index 70f6d53c..4fdcb715 100644
--- a/ccw/frontend/src/locales/en/mcp-manager.json
+++ b/ccw/frontend/src/locales/en/mcp-manager.json
@@ -164,11 +164,28 @@
"uninstall": "Uninstall",
"uninstalling": "Uninstalling...",
"uninstallConfirm": "Are you sure you want to uninstall CCW MCP?",
+ "uninstallScopeConfirm": "Are you sure you want to remove CCW MCP from {scope}?",
"saveConfig": "Save Configuration",
"saving": "Saving..."
},
+ "scope": {
+ "global": "Global",
+ "project": "Project",
+ "addScope": "Install to other scope",
+ "installToProject": "Also install to project",
+ "installToGlobal": "Also install to global",
+ "uninstallFrom": "Uninstall by scope",
+ "uninstallGlobal": "Remove from global",
+ "uninstallProject": "Remove from project"
+ },
"codexNote": "Requires: npm install -g claude-code-workflow"
},
+ "conflict": {
+ "badge": "Conflict",
+ "title": "Scope Conflict",
+ "description": "This server exists in both project and global scopes. The {scope} version is used at runtime.",
+ "resolution": "Consider removing this server from one of the scopes."
+ },
"recommended": {
"title": "Recommended Servers",
"description": "Quickly install popular MCP servers with one click",
diff --git a/ccw/frontend/src/locales/en/settings.json b/ccw/frontend/src/locales/en/settings.json
index 1eb4fafe..23755ff9 100644
--- a/ccw/frontend/src/locales/en/settings.json
+++ b/ccw/frontend/src/locales/en/settings.json
@@ -121,6 +121,7 @@
"disabled": "Disabled",
"platforms": "Platform Configuration",
"events": "Event Triggers",
+ "selectPlatforms": "Select which platforms to notify for this event:",
"noPlatforms": "No platforms",
"configured": "Configured",
"save": "Save",
@@ -151,6 +152,36 @@
"method": "HTTP Method",
"headers": "Custom Headers (JSON)",
"headersHint": "Optional JSON object with custom headers"
+ },
+ "feishu": {
+ "webhookUrl": "Webhook URL",
+ "webhookUrlHint": "Get from Feishu robot settings",
+ "useCard": "Use Card Format",
+ "useCardHint": "Send as rich interactive card",
+ "title": "Card Title (optional)"
+ },
+ "dingtalk": {
+ "webhookUrl": "Webhook URL",
+ "webhookUrlHint": "Get from DingTalk robot settings",
+ "keywords": "Security Keywords",
+ "keywordsHint": "Comma-separated keywords for security check"
+ },
+ "wecom": {
+ "webhookUrl": "Webhook URL",
+ "webhookUrlHint": "Get from WeCom robot settings",
+ "mentionedList": "Mention Users",
+ "mentionedListHint": "User IDs to mention, use '@all' for everyone"
+ },
+ "email": {
+ "host": "SMTP Host",
+ "hostHint": "e.g., smtp.gmail.com",
+ "port": "Port",
+ "secure": "Use TLS",
+ "username": "Username",
+ "password": "Password",
+ "from": "Sender Email",
+ "to": "Recipients",
+ "toHint": "Comma-separated email addresses"
}
},
"versionCheck": {
diff --git a/ccw/frontend/src/locales/en/terminal-dashboard.json b/ccw/frontend/src/locales/en/terminal-dashboard.json
index 03b00ce1..7748d37e 100644
--- a/ccw/frontend/src/locales/en/terminal-dashboard.json
+++ b/ccw/frontend/src/locales/en/terminal-dashboard.json
@@ -78,9 +78,51 @@
"layoutSplitV": "Split Vertical",
"layoutGrid": "Grid 2x2",
"launchCli": "Launch CLI",
+ "tool": "Tool",
+ "mode": "Mode",
+ "modeDefault": "Default",
+ "modeYolo": "Yolo",
"quickCreate": "Quick Create",
"configure": "Configure..."
},
+ "cliConfig": {
+ "title": "Create CLI Session",
+ "description": "Configure tool, model, mode, shell, and working directory.",
+ "tool": "Tool",
+ "model": "Model",
+ "modelAuto": "Auto",
+ "mode": "Mode",
+ "modeDefault": "Default",
+ "modeYolo": "Yolo",
+ "shell": "Shell",
+ "workingDir": "Working Directory",
+ "workingDirPlaceholder": "e.g. /path/to/project",
+ "browse": "Browse",
+ "errors": {
+ "workingDirRequired": "Working directory is required.",
+ "createFailed": "Failed to create session."
+ }
+ },
+ "fileBrowser": {
+ "title": "File Browser",
+ "open": "Open file browser",
+ "selected": "Selected file",
+ "noSelection": "No file selected",
+ "copyPath": "Copy path",
+ "copied": "Copied",
+ "insertPath": "Insert into terminal",
+ "loading": "Loading...",
+ "loadFailed": "Failed to load file tree"
+ },
+ "artifacts": {
+ "types": {
+ "workflowSession": "Workflow",
+ "liteSession": "Lite",
+ "claudeMd": "CLAUDE.md",
+ "ccwConfig": "CCW Config",
+ "issue": "Issue"
+ }
+ },
"pane": {
"selectSession": "Select a session",
"selectSessionHint": "Choose a terminal session from the dropdown",
diff --git a/ccw/frontend/src/locales/zh/mcp-manager.json b/ccw/frontend/src/locales/zh/mcp-manager.json
index c0a0d953..a0a168c8 100644
--- a/ccw/frontend/src/locales/zh/mcp-manager.json
+++ b/ccw/frontend/src/locales/zh/mcp-manager.json
@@ -164,11 +164,28 @@
"uninstall": "卸载",
"uninstalling": "卸载中...",
"uninstallConfirm": "确定要卸载 CCW MCP 吗?",
+ "uninstallScopeConfirm": "确定要从{scope}移除 CCW MCP 吗?",
"saveConfig": "保存配置",
"saving": "保存中..."
},
+ "scope": {
+ "global": "全局",
+ "project": "项目",
+ "addScope": "安装到其他作用域",
+ "installToProject": "同时安装到项目",
+ "installToGlobal": "同时安装到全局",
+ "uninstallFrom": "按作用域卸载",
+ "uninstallGlobal": "从全局移除",
+ "uninstallProject": "从项目移除"
+ },
"codexNote": "需要全局安装:npm install -g claude-code-workflow"
},
+ "conflict": {
+ "badge": "冲突",
+ "title": "作用域冲突",
+ "description": "此服务器同时存在于项目和全局作用域。运行时使用{scope}版本。",
+ "resolution": "建议从其中一个作用域移除该服务器。"
+ },
"recommended": {
"title": "推荐服务器",
"description": "一键快速安装热门 MCP 服务器",
diff --git a/ccw/frontend/src/locales/zh/settings.json b/ccw/frontend/src/locales/zh/settings.json
index 89cee92d..a4fb86e8 100644
--- a/ccw/frontend/src/locales/zh/settings.json
+++ b/ccw/frontend/src/locales/zh/settings.json
@@ -121,6 +121,7 @@
"disabled": "已禁用",
"platforms": "平台配置",
"events": "事件触发器",
+ "selectPlatforms": "选择此事件要通知的平台:",
"noPlatforms": "无平台",
"configured": "已配置",
"save": "保存",
@@ -151,6 +152,36 @@
"method": "HTTP 方法",
"headers": "自定义请求头(JSON)",
"headersHint": "可选的 JSON 对象,包含自定义请求头"
+ },
+ "feishu": {
+ "webhookUrl": "Webhook URL",
+ "webhookUrlHint": "从飞书机器人设置中获取",
+ "useCard": "使用卡片格式",
+ "useCardHint": "以富交互卡片形式发送",
+ "title": "卡片标题(可选)"
+ },
+ "dingtalk": {
+ "webhookUrl": "Webhook URL",
+ "webhookUrlHint": "从钉钉机器人设置中获取",
+ "keywords": "安全关键词",
+ "keywordsHint": "逗号分隔的关键词,用于安全校验"
+ },
+ "wecom": {
+ "webhookUrl": "Webhook URL",
+ "webhookUrlHint": "从企业微信机器人设置中获取",
+ "mentionedList": "提醒用户",
+ "mentionedListHint": "要提醒的用户 ID,使用 '@all' 提醒所有人"
+ },
+ "email": {
+ "host": "SMTP 服务器",
+ "hostHint": "例如:smtp.gmail.com",
+ "port": "端口",
+ "secure": "使用 TLS",
+ "username": "用户名",
+ "password": "密码",
+ "from": "发件人邮箱",
+ "to": "收件人",
+ "toHint": "逗号分隔的邮箱地址"
}
},
"versionCheck": {
diff --git a/ccw/frontend/src/locales/zh/terminal-dashboard.json b/ccw/frontend/src/locales/zh/terminal-dashboard.json
index 5157b444..c7fb2bdf 100644
--- a/ccw/frontend/src/locales/zh/terminal-dashboard.json
+++ b/ccw/frontend/src/locales/zh/terminal-dashboard.json
@@ -78,9 +78,51 @@
"layoutSplitV": "上下分割",
"layoutGrid": "2x2 网格",
"launchCli": "启动 CLI",
+ "tool": "工具",
+ "mode": "模式",
+ "modeDefault": "默认",
+ "modeYolo": "Yolo",
"quickCreate": "快速创建",
"configure": "配置..."
},
+ "cliConfig": {
+ "title": "创建 CLI 会话",
+ "description": "配置工具、模型、模式、Shell 与工作目录。",
+ "tool": "工具",
+ "model": "模型",
+ "modelAuto": "自动",
+ "mode": "模式",
+ "modeDefault": "默认",
+ "modeYolo": "Yolo",
+ "shell": "Shell",
+ "workingDir": "工作目录",
+ "workingDirPlaceholder": "例如:/path/to/project",
+ "browse": "浏览",
+ "errors": {
+ "workingDirRequired": "工作目录不能为空。",
+ "createFailed": "创建会话失败。"
+ }
+ },
+ "fileBrowser": {
+ "title": "文件浏览器",
+ "open": "打开文件浏览器",
+ "selected": "已选文件",
+ "noSelection": "未选择文件",
+ "copyPath": "复制路径",
+ "copied": "已复制",
+ "insertPath": "插入到终端",
+ "loading": "加载中...",
+ "loadFailed": "加载文件树失败"
+ },
+ "artifacts": {
+ "types": {
+ "workflowSession": "工作流",
+ "liteSession": "Lite",
+ "claudeMd": "CLAUDE.md",
+ "ccwConfig": "配置",
+ "issue": "问题"
+ }
+ },
"pane": {
"selectSession": "选择会话",
"selectSessionHint": "从下拉菜单中选择终端会话",
diff --git a/ccw/frontend/src/pages/McpManagerPage.tsx b/ccw/frontend/src/pages/McpManagerPage.tsx
index d9918b3a..521e67e1 100644
--- a/ccw/frontend/src/pages/McpManagerPage.tsx
+++ b/ccw/frontend/src/pages/McpManagerPage.tsx
@@ -4,7 +4,7 @@
// Manage MCP servers (Model Context Protocol) with tabbed interface
// Supports Templates, Servers, and Cross-CLI tabs
-import { useState } from 'react';
+import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -21,6 +21,7 @@ import {
ChevronDown,
ChevronUp,
BookmarkPlus,
+ AlertTriangle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -38,16 +39,20 @@ import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks';
+import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import {
fetchCodexMcpServers,
fetchCcwMcpConfig,
fetchCcwMcpConfigForCodex,
updateCcwConfig,
updateCcwConfigForCodex,
+ installCcwMcp,
+ uninstallCcwMcpFromScope,
codexRemoveServer,
codexToggleServer,
saveMcpTemplate,
type McpServer,
+ type McpServerConflict,
type CcwMcpConfig,
} from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -62,9 +67,10 @@ interface McpServerCardProps {
onEdit: (server: McpServer) => void;
onDelete: (server: McpServer) => void;
onSaveAsTemplate: (server: McpServer) => void;
+ conflictInfo?: McpServerConflict;
}
-function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate }: McpServerCardProps) {
+function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate, conflictInfo }: McpServerCardProps) {
const { formatMessage } = useIntl();
return (
@@ -97,6 +103,12 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
<> {formatMessage({ id: 'mcp.scope.project' })}>
)}
+ {conflictInfo && (
+
+
+ {formatMessage({ id: 'mcp.conflict.badge' })}
+
+ )}
{server.enabled && (
{formatMessage({ id: 'mcp.status.enabled' })}
@@ -205,6 +217,22 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
)}
+
+ {/* Conflict warning panel */}
+ {conflictInfo && (
+
+
+
+
{formatMessage({ id: 'mcp.conflict.title' })}
+
+
+ {formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: `mcp.scope.${conflictInfo.effectiveScope}` }) })}
+
+
+ {formatMessage({ id: 'mcp.conflict.resolution' })}
+
+
+ )}
)}
@@ -233,6 +261,7 @@ export function McpManagerPage() {
servers,
projectServers,
globalServers,
+ conflicts,
totalCount,
enabledCount,
isLoading,
@@ -350,6 +379,7 @@ export function McpManagerPage() {
projectRoot: undefined,
allowedDirs: undefined,
enableSandbox: undefined,
+ installedScopes: [] as ('global' | 'project')[],
};
const handleToggleCcwTool = async (tool: string, enabled: boolean) => {
@@ -401,13 +431,43 @@ export function McpManagerPage() {
ccwMcpQuery.refetch();
};
+ const projectPath = useWorkflowStore(selectProjectPath);
+
+ // Build conflict map for quick lookup
+ const conflictMap = useMemo(() => {
+ const map = new Map();
+ for (const c of conflicts) map.set(c.name, c);
+ return map;
+ }, [conflicts]);
+
+ // CCW scope-specific handlers
+ const handleCcwInstallToScope = async (scope: 'global' | 'project') => {
+ try {
+ await installCcwMcp(scope, scope === 'project' ? projectPath ?? undefined : undefined);
+ ccwMcpQuery.refetch();
+ } catch (error) {
+ console.error('Failed to install CCW MCP to scope:', error);
+ }
+ };
+
+ const handleCcwUninstallFromScope = async (scope: 'global' | 'project') => {
+ try {
+ await uninstallCcwMcpFromScope(scope, scope === 'project' ? projectPath ?? undefined : undefined);
+ ccwMcpQuery.refetch();
+ queryClient.invalidateQueries({ queryKey: ['mcpServers'] });
+ } catch (error) {
+ console.error('Failed to uninstall CCW MCP from scope:', error);
+ }
+ };
+
// CCW MCP handlers for Codex mode
const ccwCodexConfig = ccwMcpCodexQuery.data ?? {
isInstalled: false,
- enabledTools: [],
+ enabledTools: [] as string[],
projectRoot: undefined,
allowedDirs: undefined,
enableSandbox: undefined,
+ installedScopes: [] as ('global' | 'project')[],
};
const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => {
@@ -725,6 +785,9 @@ export function McpManagerPage() {
onToggleTool={handleToggleCcwTool}
onUpdateConfig={handleUpdateCcwConfig}
onInstall={handleCcwInstall}
+ installedScopes={ccwConfig.installedScopes}
+ onInstallToScope={handleCcwInstallToScope}
+ onUninstallScope={handleCcwUninstallFromScope}
/>
)}
{cliMode === 'codex' && (
@@ -761,7 +824,7 @@ export function McpManagerPage() {
{currentServers.map((server) => (
cliMode === 'codex' ? (
) : (
currentToggleExpand(server.name)}
+ isExpanded={currentExpanded.has(`${server.name}-${server.scope}`)}
+ onToggleExpand={() => currentToggleExpand(`${server.name}-${server.scope}`)}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
onSaveAsTemplate={handleSaveServerAsTemplate}
+ conflictInfo={conflictMap.get(server.name)}
/>
)
))}
diff --git a/ccw/frontend/src/types/remote-notification.ts b/ccw/frontend/src/types/remote-notification.ts
index 2bb66d18..504c2de3 100644
--- a/ccw/frontend/src/types/remote-notification.ts
+++ b/ccw/frontend/src/types/remote-notification.ts
@@ -7,7 +7,7 @@
/**
* Supported notification platforms
*/
-export type NotificationPlatform = 'discord' | 'telegram' | 'webhook';
+export type NotificationPlatform = 'discord' | 'telegram' | 'feishu' | 'dingtalk' | 'wecom' | 'email' | 'webhook';
/**
* Event types that can trigger notifications
@@ -39,6 +39,48 @@ export interface TelegramConfig {
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
}
+/**
+ * Feishu (Lark) platform configuration
+ */
+export interface FeishuConfig {
+ enabled: boolean;
+ webhookUrl: string;
+ useCard?: boolean;
+ title?: string;
+}
+
+/**
+ * DingTalk platform configuration
+ */
+export interface DingTalkConfig {
+ enabled: boolean;
+ webhookUrl: string;
+ keywords?: string[];
+}
+
+/**
+ * WeCom (WeChat Work) platform configuration
+ */
+export interface WeComConfig {
+ enabled: boolean;
+ webhookUrl: string;
+ mentionedList?: string[];
+}
+
+/**
+ * Email SMTP platform configuration
+ */
+export interface EmailConfig {
+ enabled: boolean;
+ host: string;
+ port: number;
+ secure?: boolean;
+ username: string;
+ password: string;
+ from: string;
+ to: string[];
+}
+
/**
* Generic Webhook platform configuration
*/
@@ -67,6 +109,10 @@ export interface RemoteNotificationConfig {
platforms: {
discord?: DiscordConfig;
telegram?: TelegramConfig;
+ feishu?: FeishuConfig;
+ dingtalk?: DingTalkConfig;
+ wecom?: WeComConfig;
+ email?: EmailConfig;
webhook?: WebhookConfig;
};
events: EventConfig[];
@@ -78,7 +124,7 @@ export interface RemoteNotificationConfig {
*/
export interface TestNotificationRequest {
platform: NotificationPlatform;
- config: DiscordConfig | TelegramConfig | WebhookConfig;
+ config: DiscordConfig | TelegramConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig | WebhookConfig;
}
/**
@@ -129,6 +175,34 @@ export const PLATFORM_INFO: Record = {
description: 'Send notifications to Telegram chats via bot',
requiredFields: ['botToken', 'chatId'],
},
+ feishu: {
+ id: 'feishu',
+ name: 'Feishu',
+ icon: 'message-square',
+ description: 'Send notifications to Feishu (Lark) via webhook with rich card support',
+ requiredFields: ['webhookUrl'],
+ },
+ dingtalk: {
+ id: 'dingtalk',
+ name: 'DingTalk',
+ icon: 'bell',
+ description: 'Send notifications to DingTalk via webhook',
+ requiredFields: ['webhookUrl'],
+ },
+ wecom: {
+ id: 'wecom',
+ name: 'WeCom',
+ icon: 'users',
+ description: 'Send notifications to WeCom (WeChat Work) via webhook',
+ requiredFields: ['webhookUrl'],
+ },
+ email: {
+ id: 'email',
+ name: 'Email',
+ icon: 'mail',
+ description: 'Send notifications via SMTP email',
+ requiredFields: ['host', 'username', 'password', 'from', 'to'],
+ },
webhook: {
id: 'webhook',
name: 'Custom Webhook',
diff --git a/ccw/frontend/vite.config.ts b/ccw/frontend/vite.config.ts
index f31ebd38..73f557e1 100644
--- a/ccw/frontend/vite.config.ts
+++ b/ccw/frontend/vite.config.ts
@@ -26,6 +26,12 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+ // Ensure a single React instance in Vitest (avoid invalid hook call from nested node_modules)
+ react: path.resolve(__dirname, '../../node_modules/react'),
+ 'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'),
+ 'react/jsx-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js'),
+ 'react/jsx-dev-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'),
+ 'react-dom/client': path.resolve(__dirname, '../../node_modules/react-dom/client.js'),
},
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
},
diff --git a/ccw/src/core/a2ui/A2UITypes.ts b/ccw/src/core/a2ui/A2UITypes.ts
index 2eff7a30..7df484e0 100644
--- a/ccw/src/core/a2ui/A2UITypes.ts
+++ b/ccw/src/core/a2ui/A2UITypes.ts
@@ -71,6 +71,7 @@ export type QuestionAnswer = z.infer;
export const SimpleOptionSchema = z.object({
label: z.string(),
description: z.string().optional(),
+ isDefault: z.boolean().optional(),
});
export type SimpleOption = z.infer;
@@ -114,6 +115,7 @@ export const AskQuestionResultSchema = z.object({
answers: z.array(QuestionAnswerSchema),
timestamp: z.string(),
error: z.string().optional(),
+ autoSelected: z.boolean().optional(),
});
export type AskQuestionResult = z.infer;
diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts
index e1feae6d..e7e9a406 100644
--- a/ccw/src/core/routes/hooks-routes.ts
+++ b/ccw/src/core/routes/hooks-routes.ts
@@ -8,6 +8,7 @@ import { homedir } from 'os';
import { spawn } from 'child_process';
import type { RouteContext } from './types.js';
+import { a2uiWebSocketHandler } from '../a2ui/A2UIWebSocketHandler.js';
interface HooksRouteContext extends RouteContext {
extractSessionIdFromPath: (filePath: string) => string | null;
@@ -313,6 +314,20 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise;
+ const questionId = initState.questionId as string | undefined;
+ const questionType = initState.questionType as string | undefined;
+ if (questionId && questionType === 'select') {
+ a2uiWebSocketHandler.initSingleSelect(questionId);
+ } else if (questionId && questionType === 'multi-select') {
+ a2uiWebSocketHandler.initMultiSelect(questionId);
+ }
+ }
+
broadcastToClients(notification);
return { success: true, notification };
diff --git a/ccw/src/core/routes/notification-routes.ts b/ccw/src/core/routes/notification-routes.ts
index 00d16b8e..8aa1cdcb 100644
--- a/ccw/src/core/routes/notification-routes.ts
+++ b/ccw/src/core/routes/notification-routes.ts
@@ -12,7 +12,7 @@ import {
} from '../../config/remote-notification-config.js';
import {
remoteNotificationService,
-} from '../../services/remote-notification-service.js';
+} from '../services/remote-notification-service.js';
import {
maskSensitiveConfig,
type RemoteNotificationConfig,
@@ -21,6 +21,10 @@ import {
type DiscordConfig,
type TelegramConfig,
type WebhookConfig,
+ type FeishuConfig,
+ type DingTalkConfig,
+ type WeComConfig,
+ type EmailConfig,
} from '../../types/remote-notification.js';
import { deepMerge } from '../../types/util.js';
@@ -110,13 +114,72 @@ function isValidHeaders(headers: unknown): { valid: boolean; error?: string } {
return { valid: true };
}
+/**
+ * Validate Feishu webhook URL format
+ */
+function isValidFeishuWebhookUrl(url: string): boolean {
+ if (!isValidUrl(url)) return false;
+ try {
+ const parsed = new URL(url);
+ // Feishu webhooks are typically: open.feishu.cn/open-apis/bot/v2/hook/{token}
+ // or: open.larksuite.com/open-apis/bot/v2/hook/{token}
+ const validHosts = ['open.feishu.cn', 'open.larksuite.com'];
+ return validHosts.includes(parsed.hostname) && parsed.pathname.includes('/bot/');
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Validate DingTalk webhook URL format
+ */
+function isValidDingTalkWebhookUrl(url: string): boolean {
+ if (!isValidUrl(url)) return false;
+ try {
+ const parsed = new URL(url);
+ // DingTalk webhooks are typically: oapi.dingtalk.com/robot/send?access_token=xxx
+ return parsed.hostname.includes('dingtalk.com') && parsed.pathname.includes('robot');
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Validate WeCom webhook URL format
+ */
+function isValidWeComWebhookUrl(url: string): boolean {
+ if (!isValidUrl(url)) return false;
+ try {
+ const parsed = new URL(url);
+ // WeCom webhooks are typically: qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
+ return parsed.hostname.includes('qyapi.weixin.qq.com') && parsed.pathname.includes('webhook');
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Validate email address format
+ */
+function isValidEmail(email: string): boolean {
+ // Basic email validation regex
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+/**
+ * Validate SMTP port number
+ */
+function isValidSmtpPort(port: number): boolean {
+ return Number.isInteger(port) && port > 0 && port <= 65535;
+}
+
/**
* Validate configuration updates
*/
function validateConfigUpdates(updates: Partial): { valid: boolean; error?: string } {
// Validate platforms if present
if (updates.platforms) {
- const { discord, telegram, webhook } = updates.platforms;
+ const { discord, telegram, webhook, feishu, dingtalk, wecom, email } = updates.platforms;
// Validate Discord config
if (discord) {
@@ -165,6 +228,99 @@ function validateConfigUpdates(updates: Partial): { va
return { valid: false, error: 'Webhook timeout must be between 1000ms and 60000ms' };
}
}
+
+ // Validate Feishu config
+ if (feishu) {
+ if (feishu.webhookUrl !== undefined && feishu.webhookUrl !== '') {
+ if (!isValidUrl(feishu.webhookUrl)) {
+ return { valid: false, error: 'Invalid Feishu webhook URL format' };
+ }
+ if (!isValidFeishuWebhookUrl(feishu.webhookUrl)) {
+ console.warn('[RemoteNotification] Webhook URL does not match Feishu format');
+ }
+ }
+ if (feishu.title !== undefined && feishu.title.length > 100) {
+ return { valid: false, error: 'Feishu title too long (max 100 chars)' };
+ }
+ }
+
+ // Validate DingTalk config
+ if (dingtalk) {
+ if (dingtalk.webhookUrl !== undefined && dingtalk.webhookUrl !== '') {
+ if (!isValidUrl(dingtalk.webhookUrl)) {
+ return { valid: false, error: 'Invalid DingTalk webhook URL format' };
+ }
+ if (!isValidDingTalkWebhookUrl(dingtalk.webhookUrl)) {
+ console.warn('[RemoteNotification] Webhook URL does not match DingTalk format');
+ }
+ }
+ if (dingtalk.keywords !== undefined) {
+ if (!Array.isArray(dingtalk.keywords)) {
+ return { valid: false, error: 'DingTalk keywords must be an array' };
+ }
+ if (dingtalk.keywords.length > 10) {
+ return { valid: false, error: 'Too many DingTalk keywords (max 10)' };
+ }
+ }
+ }
+
+ // Validate WeCom config
+ if (wecom) {
+ if (wecom.webhookUrl !== undefined && wecom.webhookUrl !== '') {
+ if (!isValidUrl(wecom.webhookUrl)) {
+ return { valid: false, error: 'Invalid WeCom webhook URL format' };
+ }
+ if (!isValidWeComWebhookUrl(wecom.webhookUrl)) {
+ console.warn('[RemoteNotification] Webhook URL does not match WeCom format');
+ }
+ }
+ if (wecom.mentionedList !== undefined) {
+ if (!Array.isArray(wecom.mentionedList)) {
+ return { valid: false, error: 'WeCom mentionedList must be an array' };
+ }
+ if (wecom.mentionedList.length > 100) {
+ return { valid: false, error: 'Too many mentioned users (max 100)' };
+ }
+ }
+ }
+
+ // Validate Email config
+ if (email) {
+ if (email.host !== undefined && email.host !== '') {
+ if (email.host.length > 255) {
+ return { valid: false, error: 'Email host too long (max 255 chars)' };
+ }
+ }
+ if (email.port !== undefined) {
+ if (!isValidSmtpPort(email.port)) {
+ return { valid: false, error: 'Invalid SMTP port (must be 1-65535)' };
+ }
+ }
+ if (email.username !== undefined && email.username.length > 255) {
+ return { valid: false, error: 'Email username too long (max 255 chars)' };
+ }
+ if (email.from !== undefined && email.from !== '') {
+ if (!isValidEmail(email.from)) {
+ return { valid: false, error: 'Invalid sender email address' };
+ }
+ }
+ if (email.to !== undefined) {
+ if (!Array.isArray(email.to)) {
+ return { valid: false, error: 'Email recipients must be an array' };
+ }
+ if (email.to.length === 0) {
+ return { valid: false, error: 'At least one email recipient is required' };
+ }
+ if (email.to.length > 50) {
+ return { valid: false, error: 'Too many email recipients (max 50)' };
+ }
+ for (const addr of email.to) {
+ if (!isValidEmail(addr)) {
+ return { valid: false, error: `Invalid email address: ${addr}` };
+ }
+ }
+ }
+ }
}
// Validate timeout
@@ -183,7 +339,7 @@ function validateTestRequest(request: TestNotificationRequest): { valid: boolean
return { valid: false, error: 'Missing platform' };
}
- const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook'];
+ const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook', 'feishu', 'dingtalk', 'wecom', 'email'];
if (!validPlatforms.includes(request.platform as NotificationPlatform)) {
return { valid: false, error: `Invalid platform: ${request.platform}` };
}
@@ -236,6 +392,66 @@ function validateTestRequest(request: TestNotificationRequest): { valid: boolean
}
break;
}
+ case 'feishu': {
+ const config = request.config as Partial;
+ if (!config.webhookUrl) {
+ return { valid: false, error: 'Feishu webhook URL is required' };
+ }
+ if (!isValidUrl(config.webhookUrl)) {
+ return { valid: false, error: 'Invalid Feishu webhook URL format' };
+ }
+ break;
+ }
+ case 'dingtalk': {
+ const config = request.config as Partial;
+ if (!config.webhookUrl) {
+ return { valid: false, error: 'DingTalk webhook URL is required' };
+ }
+ if (!isValidUrl(config.webhookUrl)) {
+ return { valid: false, error: 'Invalid DingTalk webhook URL format' };
+ }
+ break;
+ }
+ case 'wecom': {
+ const config = request.config as Partial;
+ if (!config.webhookUrl) {
+ return { valid: false, error: 'WeCom webhook URL is required' };
+ }
+ if (!isValidUrl(config.webhookUrl)) {
+ return { valid: false, error: 'Invalid WeCom webhook URL format' };
+ }
+ break;
+ }
+ case 'email': {
+ const config = request.config as Partial;
+ if (!config.host) {
+ return { valid: false, error: 'SMTP host is required' };
+ }
+ if (!config.username) {
+ return { valid: false, error: 'SMTP username is required' };
+ }
+ if (!config.password) {
+ return { valid: false, error: 'SMTP password is required' };
+ }
+ if (!config.from) {
+ return { valid: false, error: 'Sender email address is required' };
+ }
+ if (!isValidEmail(config.from)) {
+ return { valid: false, error: 'Invalid sender email address' };
+ }
+ if (!config.to || config.to.length === 0) {
+ return { valid: false, error: 'At least one recipient email is required' };
+ }
+ for (const addr of config.to) {
+ if (!isValidEmail(addr)) {
+ return { valid: false, error: `Invalid recipient email: ${addr}` };
+ }
+ }
+ if (config.port !== undefined && !isValidSmtpPort(config.port)) {
+ return { valid: false, error: 'Invalid SMTP port' };
+ }
+ break;
+ }
}
return { valid: true };
diff --git a/ccw/src/core/services/remote-notification-service.ts b/ccw/src/core/services/remote-notification-service.ts
index 4d6bdf08..12c32074 100644
--- a/ccw/src/core/services/remote-notification-service.ts
+++ b/ccw/src/core/services/remote-notification-service.ts
@@ -16,6 +16,10 @@ import type {
DiscordConfig,
TelegramConfig,
WebhookConfig,
+ FeishuConfig,
+ DingTalkConfig,
+ WeComConfig,
+ EmailConfig,
} from '../../types/remote-notification.js';
import {
loadConfig,
@@ -170,6 +174,14 @@ class RemoteNotificationService {
return await this.sendTelegram(context, config.platforms.telegram!, config.timeout);
case 'webhook':
return await this.sendWebhook(context, config.platforms.webhook!, config.timeout);
+ case 'feishu':
+ return await this.sendFeishu(context, config.platforms.feishu!, config.timeout);
+ case 'dingtalk':
+ return await this.sendDingTalk(context, config.platforms.dingtalk!, config.timeout);
+ case 'wecom':
+ return await this.sendWeCom(context, config.platforms.wecom!, config.timeout);
+ case 'email':
+ return await this.sendEmail(context, config.platforms.email!, config.timeout);
default:
return {
platform,
@@ -408,6 +420,538 @@ class RemoteNotificationService {
}
}
+ /**
+ * Send Feishu notification via webhook
+ * Supports both rich card format and simple text format
+ */
+ private async sendFeishu(
+ context: NotificationContext,
+ config: FeishuConfig,
+ timeout: number
+ ): Promise {
+ const startTime = Date.now();
+
+ if (!config.webhookUrl) {
+ return { platform: 'feishu', success: false, error: 'Webhook URL not configured' };
+ }
+
+ const useCard = config.useCard !== false; // Default to true
+
+ try {
+ let body: unknown;
+
+ if (useCard) {
+ // Rich card format
+ const card = this.buildFeishuCard(context, config);
+ body = {
+ msg_type: 'interactive',
+ card,
+ };
+ } else {
+ // Simple text format
+ const text = this.buildFeishuText(context);
+ body = {
+ msg_type: 'post',
+ content: {
+ post: {
+ zh_cn: {
+ title: config.title || 'CCW Notification',
+ content: [[{ tag: 'text', text }]],
+ },
+ },
+ },
+ };
+ }
+
+ await this.httpRequest(config.webhookUrl, body, timeout);
+ return {
+ platform: 'feishu',
+ success: true,
+ responseTime: Date.now() - startTime,
+ };
+ } catch (error) {
+ return {
+ platform: 'feishu',
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ responseTime: Date.now() - startTime,
+ };
+ }
+ }
+
+ /**
+ * Build Feishu interactive card from context
+ */
+ private buildFeishuCard(context: NotificationContext, config: FeishuConfig): Record {
+ const elements: Array> = [];
+
+ // Add event type as header
+ elements.push({
+ tag: 'markdown',
+ content: `**${this.formatEventName(context.eventType)}**`,
+ text_align: 'left' as const,
+ text_size: 'normal_v2' as const,
+ });
+
+ // Add session info
+ if (context.sessionId) {
+ elements.push({
+ tag: 'markdown',
+ content: `**Session:** ${context.sessionId.slice(0, 16)}...`,
+ text_align: 'left' as const,
+ text_size: 'normal_v2' as const,
+ });
+ }
+
+ // Add question text
+ if (context.questionText) {
+ const truncated = context.questionText.length > 300
+ ? context.questionText.slice(0, 300) + '...'
+ : context.questionText;
+ elements.push({
+ tag: 'markdown',
+ content: `**Question:** ${this.escapeFeishuMarkdown(truncated)}`,
+ text_align: 'left' as const,
+ text_size: 'normal_v2' as const,
+ });
+ }
+
+ // Add task description
+ if (context.taskDescription) {
+ const truncated = context.taskDescription.length > 300
+ ? context.taskDescription.slice(0, 300) + '...'
+ : context.taskDescription;
+ elements.push({
+ tag: 'markdown',
+ content: `**Task:** ${this.escapeFeishuMarkdown(truncated)}`,
+ text_align: 'left' as const,
+ text_size: 'normal_v2' as const,
+ });
+ }
+
+ // Add error message
+ if (context.errorMessage) {
+ const truncated = context.errorMessage.length > 300
+ ? context.errorMessage.slice(0, 300) + '...'
+ : context.errorMessage;
+ elements.push({
+ tag: 'markdown',
+ content: `**Error:** ${this.escapeFeishuMarkdown(truncated)}`,
+ text_align: 'left' as const,
+ text_size: 'normal_v2' as const,
+ });
+ }
+
+ // Add timestamp
+ elements.push({
+ tag: 'markdown',
+ content: `**Time:** ${new Date(context.timestamp).toLocaleString()}`,
+ text_align: 'left' as const,
+ text_size: 'normal_v2' as const,
+ });
+
+ return {
+ schema: '2.0',
+ config: {
+ update_multi: true,
+ style: {
+ text_size: {
+ normal_v2: {
+ default: 'normal',
+ pc: 'normal',
+ mobile: 'heading',
+ },
+ },
+ },
+ },
+ header: {
+ title: {
+ tag: 'plain_text',
+ content: config.title || 'CCW Notification',
+ },
+ template: 'wathet',
+ padding: '12px 12px 12px 12px',
+ },
+ body: {
+ direction: 'vertical',
+ horizontal_spacing: '8px',
+ vertical_spacing: '8px',
+ horizontal_align: 'left',
+ vertical_align: 'top',
+ padding: '12px 12px 12px 12px',
+ elements,
+ },
+ };
+ }
+
+ /**
+ * Build Feishu simple text message
+ */
+ private buildFeishuText(context: NotificationContext): string {
+ const lines: string[] = [];
+ lines.push(`Event: ${this.formatEventName(context.eventType)}`);
+
+ if (context.sessionId) {
+ lines.push(`Session: ${context.sessionId.slice(0, 16)}...`);
+ }
+ if (context.questionText) {
+ const truncated = context.questionText.length > 200
+ ? context.questionText.slice(0, 200) + '...'
+ : context.questionText;
+ lines.push(`Question: ${truncated}`);
+ }
+ if (context.taskDescription) {
+ const truncated = context.taskDescription.length > 200
+ ? context.taskDescription.slice(0, 200) + '...'
+ : context.taskDescription;
+ lines.push(`Task: ${truncated}`);
+ }
+ if (context.errorMessage) {
+ const truncated = context.errorMessage.length > 200
+ ? context.errorMessage.slice(0, 200) + '...'
+ : context.errorMessage;
+ lines.push(`Error: ${truncated}`);
+ }
+ lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
+
+ return lines.join('\n');
+ }
+
+ /**
+ * Escape special characters for Feishu markdown
+ */
+ private escapeFeishuMarkdown(text: string): string {
+ return text
+ .replace(//g, '>');
+ }
+
+ /**
+ * Send DingTalk notification via webhook
+ */
+ private async sendDingTalk(
+ context: NotificationContext,
+ config: DingTalkConfig,
+ timeout: number
+ ): Promise {
+ const startTime = Date.now();
+
+ if (!config.webhookUrl) {
+ return { platform: 'dingtalk', success: false, error: 'Webhook URL not configured' };
+ }
+
+ const text = this.buildDingTalkText(context, config.keywords);
+
+ const body = {
+ msgtype: 'text',
+ text: {
+ content: text,
+ },
+ };
+
+ try {
+ await this.httpRequest(config.webhookUrl, body, timeout);
+ return {
+ platform: 'dingtalk',
+ success: true,
+ responseTime: Date.now() - startTime,
+ };
+ } catch (error) {
+ return {
+ platform: 'dingtalk',
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ responseTime: Date.now() - startTime,
+ };
+ }
+ }
+
+ /**
+ * Build DingTalk text message
+ */
+ private buildDingTalkText(context: NotificationContext, keywords?: string[]): string {
+ const lines: string[] = [];
+
+ // Add keywords at the beginning if configured (for security check)
+ if (keywords && keywords.length > 0) {
+ lines.push(`[${keywords[0]}]`);
+ }
+
+ lines.push(`Event: ${this.formatEventName(context.eventType)}`);
+
+ if (context.sessionId) {
+ lines.push(`Session: ${context.sessionId.slice(0, 16)}...`);
+ }
+ if (context.questionText) {
+ const truncated = context.questionText.length > 200
+ ? context.questionText.slice(0, 200) + '...'
+ : context.questionText;
+ lines.push(`Question: ${truncated}`);
+ }
+ if (context.taskDescription) {
+ const truncated = context.taskDescription.length > 200
+ ? context.taskDescription.slice(0, 200) + '...'
+ : context.taskDescription;
+ lines.push(`Task: ${truncated}`);
+ }
+ if (context.errorMessage) {
+ const truncated = context.errorMessage.length > 200
+ ? context.errorMessage.slice(0, 200) + '...'
+ : context.errorMessage;
+ lines.push(`Error: ${truncated}`);
+ }
+ lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
+
+ return lines.join('\n');
+ }
+
+ /**
+ * Send WeCom (WeChat Work) notification via webhook
+ */
+ private async sendWeCom(
+ context: NotificationContext,
+ config: WeComConfig,
+ timeout: number
+ ): Promise {
+ const startTime = Date.now();
+
+ if (!config.webhookUrl) {
+ return { platform: 'wecom', success: false, error: 'Webhook URL not configured' };
+ }
+
+ const markdown = this.buildWeComMarkdown(context);
+
+ const body: Record = {
+ msgtype: 'markdown',
+ markdown: {
+ content: markdown,
+ },
+ };
+
+ // Add mentioned list if configured
+ if (config.mentionedList && config.mentionedList.length > 0) {
+ body.text = {
+ content: markdown,
+ mentioned_list: config.mentionedList,
+ };
+ }
+
+ try {
+ await this.httpRequest(config.webhookUrl, body, timeout);
+ return {
+ platform: 'wecom',
+ success: true,
+ responseTime: Date.now() - startTime,
+ };
+ } catch (error) {
+ return {
+ platform: 'wecom',
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ responseTime: Date.now() - startTime,
+ };
+ }
+ }
+
+ /**
+ * Build WeCom markdown message
+ */
+ private buildWeComMarkdown(context: NotificationContext): string {
+ const lines: string[] = [];
+ lines.push(`### ${this.formatEventName(context.eventType)}`);
+ lines.push('');
+
+ if (context.sessionId) {
+ lines.push(`> Session: \`${context.sessionId.slice(0, 16)}...\``);
+ }
+ if (context.questionText) {
+ const truncated = context.questionText.length > 200
+ ? context.questionText.slice(0, 200) + '...'
+ : context.questionText;
+ lines.push(`**Question:** ${truncated}`);
+ }
+ if (context.taskDescription) {
+ const truncated = context.taskDescription.length > 200
+ ? context.taskDescription.slice(0, 200) + '...'
+ : context.taskDescription;
+ lines.push(`**Task:** ${truncated}`);
+ }
+ if (context.errorMessage) {
+ const truncated = context.errorMessage.length > 200
+ ? context.errorMessage.slice(0, 200) + '...'
+ : context.errorMessage;
+ lines.push(`**Error:** ${truncated} `);
+ }
+ lines.push('');
+ lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
+
+ return lines.join('\n');
+ }
+
+ /**
+ * Send Email notification via SMTP
+ */
+ private async sendEmail(
+ context: NotificationContext,
+ config: EmailConfig,
+ timeout: number
+ ): Promise {
+ const startTime = Date.now();
+
+ if (!config.host || !config.username || !config.password || !config.from || !config.to || config.to.length === 0) {
+ return { platform: 'email', success: false, error: 'Email configuration incomplete (host, username, password, from, to required)' };
+ }
+
+ try {
+ // Dynamic import for nodemailer (optional dependency)
+ const nodemailer = await this.loadNodemailer();
+
+ const transporter = nodemailer.createTransport({
+ host: config.host,
+ port: config.port || 465,
+ secure: config.secure !== false, // Default to true for port 465
+ auth: {
+ user: config.username,
+ pass: config.password,
+ },
+ });
+
+ const { subject, html } = this.buildEmailContent(context);
+
+ // Set timeout for email sending
+ await Promise.race([
+ transporter.sendMail({
+ from: config.from,
+ to: config.to.join(', '),
+ subject,
+ html,
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Email send timeout')), timeout)
+ ),
+ ]);
+
+ return {
+ platform: 'email',
+ success: true,
+ responseTime: Date.now() - startTime,
+ };
+ } catch (error) {
+ return {
+ platform: 'email',
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ responseTime: Date.now() - startTime,
+ };
+ }
+ }
+
+ /**
+ * Load nodemailer module (optional dependency)
+ */
+ private async loadNodemailer(): Promise<{
+ createTransport: (options: Record) => {
+ sendMail: (mailOptions: Record) => Promise;
+ };
+ }> {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ return require('nodemailer');
+ } catch {
+ throw new Error('nodemailer not installed. Run: npm install nodemailer');
+ }
+ }
+
+ /**
+ * Build email subject and HTML content
+ */
+ private buildEmailContent(context: NotificationContext): { subject: string; html: string } {
+ const subject = `[CCW] ${this.formatEventName(context.eventType)}`;
+
+ const htmlParts: string[] = [];
+ htmlParts.push('');
+ htmlParts.push('');
+ htmlParts.push('');
+ htmlParts.push(' ');
+ htmlParts.push('');
+ htmlParts.push('');
+ htmlParts.push('');
+ htmlParts.push('');
+
+ // Header
+ htmlParts.push('');
+
+ // Content
+ htmlParts.push('
');
+
+ if (context.sessionId) {
+ htmlParts.push('
');
+ htmlParts.push('
Session
');
+ htmlParts.push(`
${context.sessionId}
`);
+ htmlParts.push('
');
+ }
+
+ if (context.questionText) {
+ const truncated = context.questionText.length > 500
+ ? context.questionText.slice(0, 500) + '...'
+ : context.questionText;
+ htmlParts.push('
');
+ htmlParts.push('
Question
');
+ htmlParts.push(`
${this.escapeHtml(truncated).replace(/\n/g, ' ')}
`);
+ htmlParts.push('
');
+ }
+
+ if (context.taskDescription) {
+ const truncated = context.taskDescription.length > 500
+ ? context.taskDescription.slice(0, 500) + '...'
+ : context.taskDescription;
+ htmlParts.push('
');
+ htmlParts.push('
Task
');
+ htmlParts.push(`
${this.escapeHtml(truncated).replace(/\n/g, ' ')}
`);
+ htmlParts.push('
');
+ }
+
+ if (context.errorMessage) {
+ const truncated = context.errorMessage.length > 500
+ ? context.errorMessage.slice(0, 500) + '...'
+ : context.errorMessage;
+ htmlParts.push('
');
+ htmlParts.push('
Error
');
+ htmlParts.push(`
${this.escapeHtml(truncated).replace(/\n/g, ' ')}
`);
+ htmlParts.push('
');
+ }
+
+ htmlParts.push('
');
+ htmlParts.push('
Timestamp
');
+ htmlParts.push(`
${new Date(context.timestamp).toLocaleString()}
`);
+ htmlParts.push('
');
+
+ htmlParts.push('
'); // content
+
+ // Footer
+ htmlParts.push('');
+
+ htmlParts.push('
'); // container
+ htmlParts.push('');
+ htmlParts.push('');
+
+ return { subject, html: htmlParts.join('\n') };
+ }
+
/**
* Check if a URL is safe from SSRF attacks
* Blocks private IP ranges, loopback, and link-local addresses
@@ -556,7 +1100,7 @@ class RemoteNotificationService {
*/
async testPlatform(
platform: NotificationPlatform,
- config: DiscordConfig | TelegramConfig | WebhookConfig
+ config: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
): Promise<{ success: boolean; error?: string; responseTime?: number }> {
const testContext: NotificationContext = {
eventType: 'task-completed',
@@ -575,6 +1119,14 @@ class RemoteNotificationService {
return await this.sendTelegram(testContext, config as TelegramConfig, 10000);
case 'webhook':
return await this.sendWebhook(testContext, config as WebhookConfig, 10000);
+ case 'feishu':
+ return await this.sendFeishu(testContext, config as FeishuConfig, 10000);
+ case 'dingtalk':
+ return await this.sendDingTalk(testContext, config as DingTalkConfig, 10000);
+ case 'wecom':
+ return await this.sendWeCom(testContext, config as WeComConfig, 10000);
+ case 'email':
+ return await this.sendEmail(testContext, config as EmailConfig, 30000); // Longer timeout for email
default:
return { success: false, error: `Unknown platform: ${platform}` };
}
diff --git a/ccw/src/tools/ask-question.ts b/ccw/src/tools/ask-question.ts
index 86430cf4..669f253f 100644
--- a/ccw/src/tools/ask-question.ts
+++ b/ccw/src/tools/ask-question.ts
@@ -163,17 +163,22 @@ function normalizeSimpleQuestion(simple: SimpleQuestion): Question {
type = 'input';
}
- const options: QuestionOption[] | undefined = simple.options?.map((opt) => ({
- value: opt.label,
- label: opt.label,
- description: opt.description,
- }));
+ let defaultValue: string | undefined;
+ const options: QuestionOption[] | undefined = simple.options?.map((opt) => {
+ const isDefault = opt.isDefault === true
+ || /\(Recommended\)/i.test(opt.label);
+ if (isDefault && !defaultValue) {
+ defaultValue = opt.label;
+ }
+ return { value: opt.label, label: opt.label, description: opt.description };
+ });
return {
id: simple.header,
type,
title: simple.question,
options,
+ ...(defaultValue !== undefined && { defaultValue }),
} as Question;
}
@@ -192,7 +197,7 @@ function isSimpleFormat(params: Record): params is { questions:
* @param surfaceId - Surface ID for the question
* @returns A2UI surface update object
*/
-function generateQuestionSurface(question: Question, surfaceId: string): {
+function generateQuestionSurface(question: Question, surfaceId: string, timeoutMs: number): {
surfaceUpdate: {
surfaceId: string;
components: unknown[];
@@ -274,6 +279,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
+ isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue),
})) || [];
// Add "Other" option for custom input
@@ -281,6 +287,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
label: { literalString: 'Other' },
value: '__other__',
description: { literalString: 'Provide a custom answer' },
+ isDefault: false,
});
// Use RadioGroup for direct selection display (not dropdown)
@@ -411,6 +418,8 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
questionType: question.type,
options: question.options,
required: question.required,
+ timeoutAt: new Date(Date.now() + timeoutMs).toISOString(),
+ ...(question.defaultValue !== undefined && { defaultValue: question.defaultValue }),
},
/** Display mode: 'popup' for centered dialog (interactive questions) */
displayMode: 'popup' as const,
@@ -451,20 +460,31 @@ export async function execute(params: AskQuestionParams): Promise {
if (pendingQuestions.has(question.id)) {
pendingQuestions.delete(question.id);
- resolve({
- success: false,
- surfaceId,
- cancelled: false,
- answers: [],
- timestamp: new Date().toISOString(),
- error: 'Question timed out',
- });
+ if (question.defaultValue !== undefined) {
+ resolve({
+ success: true,
+ surfaceId,
+ cancelled: false,
+ answers: [{ questionId: question.id, value: question.defaultValue as string | string[] | boolean, cancelled: false }],
+ timestamp: new Date().toISOString(),
+ autoSelected: true,
+ });
+ } else {
+ resolve({
+ success: false,
+ surfaceId,
+ cancelled: false,
+ answers: [],
+ timestamp: new Date().toISOString(),
+ error: 'Question timed out',
+ });
+ }
}
}, params.timeout || DEFAULT_TIMEOUT_MS);
});
// Send A2UI surface via WebSocket to frontend
- const a2uiSurface = generateQuestionSurface(question, surfaceId);
+ const a2uiSurface = generateQuestionSurface(question, surfaceId, params.timeout || DEFAULT_TIMEOUT_MS);
const sentCount = a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
// Trigger remote notification for ask-user-question event (if enabled)
@@ -594,9 +614,17 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
if (isComposite && Array.isArray(parsed.answers)) {
const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`);
+ if (!ok && pendingQuestions.has(questionId)) {
+ // Answer consumed but delivery failed; keep polling for a new answer
+ setTimeout(poll, POLL_INTERVAL_MS);
+ }
} else if (!isComposite && parsed.answer) {
const ok = handleAnswer(parsed.answer as QuestionAnswer);
console.error(`[A2UI-Poll] handleAnswer result: ${ok}`);
+ if (!ok && pendingQuestions.has(questionId)) {
+ // Answer consumed but validation/delivery failed; keep polling for a new answer
+ setTimeout(poll, POLL_INTERVAL_MS);
+ }
} else {
console.error(`[A2UI-Poll] Unexpected response shape, keep polling`);
setTimeout(poll, POLL_INTERVAL_MS);
@@ -873,6 +901,7 @@ function generateMultiQuestionSurface(
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
+ isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue),
})) || [];
// Add "Other" option for custom input
@@ -880,6 +909,7 @@ function generateMultiQuestionSurface(
label: { literalString: 'Other' },
value: '__other__',
description: { literalString: 'Provide a custom answer' },
+ isDefault: false,
});
components.push({
@@ -997,7 +1027,8 @@ async function executeSimpleFormat(
return result;
}
- if (result.result.cancelled) {
+ // Propagate inner failures (e.g. timeout) — don't mask them as success
+ if (result.result.cancelled || !result.result.success) {
return result;
}
@@ -1058,14 +1089,33 @@ async function executeSimpleFormat(
setTimeout(() => {
if (pendingQuestions.has(compositeId)) {
pendingQuestions.delete(compositeId);
- resolve({
- success: false,
- surfaceId,
- cancelled: false,
- answers: [],
- timestamp: new Date().toISOString(),
- error: 'Question timed out',
- });
+ // Collect default values from each sub-question
+ const defaultAnswers: QuestionAnswer[] = [];
+ for (const simpleQ of questions) {
+ const q = normalizeSimpleQuestion(simpleQ);
+ if (q.defaultValue !== undefined) {
+ defaultAnswers.push({ questionId: q.id, value: q.defaultValue as string | string[] | boolean, cancelled: false });
+ }
+ }
+ if (defaultAnswers.length > 0) {
+ resolve({
+ success: true,
+ surfaceId,
+ cancelled: false,
+ answers: defaultAnswers,
+ timestamp: new Date().toISOString(),
+ autoSelected: true,
+ });
+ } else {
+ resolve({
+ success: false,
+ surfaceId,
+ cancelled: false,
+ answers: [],
+ timestamp: new Date().toISOString(),
+ error: 'Question timed out',
+ });
+ }
}
}, timeout ?? DEFAULT_TIMEOUT_MS);
});
diff --git a/ccw/src/types/remote-notification.ts b/ccw/src/types/remote-notification.ts
index c7968131..cec678f7 100644
--- a/ccw/src/types/remote-notification.ts
+++ b/ccw/src/types/remote-notification.ts
@@ -2,12 +2,12 @@
// Remote Notification Types
// ========================================
// Type definitions for remote notification system
-// Supports Discord, Telegram, and Generic Webhook platforms
+// Supports Discord, Telegram, Feishu, DingTalk, WeCom, Email, and Generic Webhook platforms
/**
* Supported notification platforms
*/
-export type NotificationPlatform = 'discord' | 'telegram' | 'webhook';
+export type NotificationPlatform = 'discord' | 'telegram' | 'feishu' | 'dingtalk' | 'wecom' | 'email' | 'webhook';
/**
* Event types that can trigger notifications
@@ -47,6 +47,66 @@ export interface TelegramConfig {
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
}
+/**
+ * Feishu (Lark) platform configuration
+ */
+export interface FeishuConfig {
+ /** Whether Feishu notifications are enabled */
+ enabled: boolean;
+ /** Feishu webhook URL */
+ webhookUrl: string;
+ /** Use rich card format (default: true) */
+ useCard?: boolean;
+ /** Custom title for notifications */
+ title?: string;
+}
+
+/**
+ * DingTalk platform configuration
+ */
+export interface DingTalkConfig {
+ /** Whether DingTalk notifications are enabled */
+ enabled: boolean;
+ /** DingTalk webhook URL */
+ webhookUrl: string;
+ /** Optional keywords for security check */
+ keywords?: string[];
+}
+
+/**
+ * WeCom (WeChat Work) platform configuration
+ */
+export interface WeComConfig {
+ /** Whether WeCom notifications are enabled */
+ enabled: boolean;
+ /** WeCom webhook URL */
+ webhookUrl: string;
+ /** Mentioned user IDs (@all for all members) */
+ mentionedList?: string[];
+}
+
+/**
+ * Email SMTP platform configuration
+ */
+export interface EmailConfig {
+ /** Whether Email notifications are enabled */
+ enabled: boolean;
+ /** SMTP server host */
+ host: string;
+ /** SMTP server port */
+ port: number;
+ /** Use secure connection (TLS) */
+ secure?: boolean;
+ /** SMTP username */
+ username: string;
+ /** SMTP password */
+ password: string;
+ /** Sender email address */
+ from: string;
+ /** Recipient email addresses */
+ to: string[];
+}
+
/**
* Generic Webhook platform configuration
*/
@@ -85,6 +145,10 @@ export interface RemoteNotificationConfig {
platforms: {
discord?: DiscordConfig;
telegram?: TelegramConfig;
+ feishu?: FeishuConfig;
+ dingtalk?: DingTalkConfig;
+ wecom?: WeComConfig;
+ email?: EmailConfig;
webhook?: WebhookConfig;
};
/** Event-to-platform mappings */
@@ -192,6 +256,22 @@ export function maskSensitiveConfig(config: RemoteNotificationConfig): RemoteNot
...config.platforms.telegram,
botToken: maskToken(config.platforms.telegram.botToken),
} : undefined,
+ feishu: config.platforms.feishu ? {
+ ...config.platforms.feishu,
+ webhookUrl: maskWebhookUrl(config.platforms.feishu.webhookUrl),
+ } : undefined,
+ dingtalk: config.platforms.dingtalk ? {
+ ...config.platforms.dingtalk,
+ webhookUrl: maskWebhookUrl(config.platforms.dingtalk.webhookUrl),
+ } : undefined,
+ wecom: config.platforms.wecom ? {
+ ...config.platforms.wecom,
+ webhookUrl: maskWebhookUrl(config.platforms.wecom.webhookUrl),
+ } : undefined,
+ email: config.platforms.email ? {
+ ...config.platforms.email,
+ password: maskToken(config.platforms.email.password),
+ } : undefined,
webhook: config.platforms.webhook ? {
...config.platforms.webhook,
// Don't mask webhook URL as it's needed for display
diff --git a/ccw/tsconfig.tsbuildinfo b/ccw/tsconfig.tsbuildinfo
new file mode 100644
index 00000000..cb4f03a5
--- /dev/null
+++ b/ccw/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/cli.ts","./src/index.ts","./src/commands/cli.ts","./src/commands/core-memory.ts","./src/commands/hook.ts","./src/commands/install.ts","./src/commands/issue.ts","./src/commands/list.ts","./src/commands/loop.ts","./src/commands/memory.ts","./src/commands/serve.ts","./src/commands/session-path-resolver.ts","./src/commands/session.ts","./src/commands/stop.ts","./src/commands/team.ts","./src/commands/tool.ts","./src/commands/uninstall.ts","./src/commands/upgrade.ts","./src/commands/view.ts","./src/commands/workflow.ts","./src/config/cli-settings-manager.ts","./src/config/litellm-api-config-manager.ts","./src/config/litellm-provider-models.ts","./src/config/provider-models.ts","./src/config/remote-notification-config.ts","./src/config/storage-paths.ts","./src/core/cache-manager.ts","./src/core/claude-freshness.ts","./src/core/core-memory-store.ts","./src/core/cors.ts","./src/core/data-aggregator.ts","./src/core/history-importer.ts","./src/core/lite-scanner-complete.ts","./src/core/lite-scanner.ts","./src/core/manifest.ts","./src/core/memory-consolidation-pipeline.ts","./src/core/memory-consolidation-prompts.ts","./src/core/memory-embedder-bridge.ts","./src/core/memory-extraction-pipeline.ts","./src/core/memory-extraction-prompts.ts","./src/core/memory-job-scheduler.ts","./src/core/memory-store.ts","./src/core/memory-v2-config.ts","./src/core/pattern-detector.ts","./src/core/server.ts","./src/core/session-clustering-service.ts","./src/core/session-scanner.ts","./src/core/unified-context-builder.ts","./src/core/unified-memory-service.ts","./src/core/unified-vector-index.ts","./src/core/websocket.ts","./src/core/a2ui/a2uitypes.ts","./src/core/a2ui/a2uiwebsockethandler.ts","./src/core/a2ui/index.ts","./src/core/auth/csrf-manager.ts","./src/core/auth/csrf-middleware.ts","./src/core/auth/middleware.ts","./src/core/auth/token-manager.ts","./src/core/routes/audit-routes.ts","./src/core/routes/auth-routes.ts","./src/core/routes/ccw-routes.ts","./src/core/routes/claude-routes.ts","./src/core/routes/cli-routes.ts","./src/core/routes/cli-sessions-routes.ts","./src/core/routes/cli-settings-routes.ts","./src/core/routes/codexlens-routes.ts","./src/core/routes/commands-routes.ts","./src/core/routes/config-routes.ts","./src/core/routes/core-memory-routes.ts","./src/core/routes/dashboard-routes.ts","./src/core/routes/discovery-routes.ts","./src/core/routes/files-routes.ts","./src/core/routes/graph-routes.ts","./src/core/routes/help-routes.ts","./src/core/routes/hooks-routes.ts","./src/core/routes/issue-routes.ts","./src/core/routes/litellm-api-routes.ts","./src/core/routes/litellm-routes.ts","./src/core/routes/loop-routes.ts","./src/core/routes/loop-v2-routes.ts","./src/core/routes/mcp-routes.ts","./src/core/routes/mcp-templates-db.ts","./src/core/routes/memory-routes.ts","./src/core/routes/nav-status-routes.ts","./src/core/routes/notification-routes.ts","./src/core/routes/orchestrator-routes.ts","./src/core/routes/provider-routes.ts","./src/core/routes/rules-routes.ts","./src/core/routes/session-routes.ts","./src/core/routes/skills-routes.ts","./src/core/routes/status-routes.ts","./src/core/routes/system-routes.ts","./src/core/routes/task-routes.ts","./src/core/routes/team-routes.ts","./src/core/routes/test-loop-routes.ts","./src/core/routes/types.ts","./src/core/routes/unified-memory-routes.ts","./src/core/routes/unsplash-routes.ts","./src/core/routes/codexlens/config-handlers.ts","./src/core/routes/codexlens/index-handlers.ts","./src/core/routes/codexlens/semantic-handlers.ts","./src/core/routes/codexlens/utils.ts","./src/core/routes/codexlens/watcher-handlers.ts","./src/core/services/api-key-tester.ts","./src/core/services/cli-session-audit.ts","./src/core/services/cli-session-command-builder.ts","./src/core/services/cli-session-manager.ts","./src/core/services/cli-session-mux.ts","./src/core/services/cli-session-policy.ts","./src/core/services/cli-session-share.ts","./src/core/services/config-backup.ts","./src/core/services/config-sync.ts","./src/core/services/flow-executor.ts","./src/core/services/health-check-service.ts","./src/core/services/rate-limiter.ts","./src/core/services/remote-notification-service.ts","./src/core/services/version-checker.ts","./src/mcp-server/index.ts","./src/tools/ask-question.ts","./src/tools/classify-folders.ts","./src/tools/claude-cli-tools.ts","./src/tools/cli-config-manager.ts","./src/tools/cli-executor-core.ts","./src/tools/cli-executor-state.ts","./src/tools/cli-executor-utils.ts","./src/tools/cli-executor.ts","./src/tools/cli-history-store.ts","./src/tools/cli-output-converter.ts","./src/tools/cli-prompt-builder.ts","./src/tools/codex-lens-lsp.ts","./src/tools/codex-lens.ts","./src/tools/command-registry.ts","./src/tools/context-cache-store.ts","./src/tools/context-cache.ts","./src/tools/convert-tokens-to-css.ts","./src/tools/core-memory.ts","./src/tools/detect-changed-modules.ts","./src/tools/discover-design-files.ts","./src/tools/edit-file.ts","./src/tools/generate-module-docs.ts","./src/tools/get-modules-by-depth.ts","./src/tools/index.ts","./src/tools/litellm-client.ts","./src/tools/litellm-executor.ts","./src/tools/loop-manager.ts","./src/tools/loop-state-manager.ts","./src/tools/loop-task-manager.ts","./src/tools/memory-update-queue.js","./src/tools/native-session-discovery.ts","./src/tools/notifier.ts","./src/tools/pattern-parser.ts","./src/tools/read-file.ts","./src/tools/read-many-files.ts","./src/tools/read-outline.ts","./src/tools/resume-strategy.ts","./src/tools/session-content-parser.ts","./src/tools/session-manager.ts","./src/tools/skill-context-loader.ts","./src/tools/smart-context.ts","./src/tools/smart-search.ts","./src/tools/storage-manager.ts","./src/tools/team-msg.ts","./src/tools/template-discovery.ts","./src/tools/ui-generate-preview.js","./src/tools/ui-instantiate-prototypes.js","./src/tools/update-module-claude.js","./src/tools/vscode-lsp.ts","./src/tools/write-file.ts","./src/types/cli-settings.ts","./src/types/config.ts","./src/types/index.ts","./src/types/litellm-api-config.ts","./src/types/loop.ts","./src/types/remote-notification.ts","./src/types/session.ts","./src/types/skill-types.ts","./src/types/tool.ts","./src/types/util.ts","./src/utils/browser-launcher.ts","./src/utils/codexlens-path.ts","./src/utils/db-loader.ts","./src/utils/exec-constants.ts","./src/utils/file-reader.ts","./src/utils/file-utils.ts","./src/utils/outline-parser.ts","./src/utils/outline-queries.ts","./src/utils/path-resolver.ts","./src/utils/path-validator.ts","./src/utils/project-root.ts","./src/utils/python-utils.ts","./src/utils/react-frontend.ts","./src/utils/secret-redactor.ts","./src/utils/security-validation.ts","./src/utils/shell-escape.ts","./src/utils/ui.ts","./src/utils/update-checker.ts","./src/utils/uv-manager.ts"],"errors":true,"version":"5.9.3"}
\ No newline at end of file
diff --git a/codex-lens/docs/CONFIGURATION.md b/codex-lens/docs/CONFIGURATION.md
index 082ca933..f155c088 100644
--- a/codex-lens/docs/CONFIGURATION.md
+++ b/codex-lens/docs/CONFIGURATION.md
@@ -125,6 +125,13 @@ CODEXLENS_DEBUG=false
"tool": "gemini",
"timeout_ms": 300000,
"batch_size": 5
+ },
+ "parsing": {
+ "use_astgrep": false
+ },
+ "indexing": {
+ "static_graph_enabled": false,
+ "static_graph_relationship_types": ["imports", "inherits"]
}
}
```
@@ -167,6 +174,32 @@ CODEXLENS_DEBUG=false
| `timeout_ms` | int | 超时时间 (毫秒) |
| `batch_size` | int | 批处理大小 |
+### Parsing 设置
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `use_astgrep` | bool | 优先使用 ast-grep 解析关系(实验性;当前主要用于 Python relationships) |
+
+### Indexing 设置(静态图)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `static_graph_enabled` | bool | 索引时将 relationships 写入全局 `global_relationships`,用于搜索阶段静态图扩展 |
+| `static_graph_relationship_types` | array | 允许持久化的关系类型:`imports` / `inherits` / `calls` |
+
+**CLI 覆盖(单次运行,不写入 settings.json)**:
+
+```bash
+# 索引时启用静态图 relationships + 使用 ast-grep(如果可用)
+codexlens index init --use-astgrep --static-graph --static-graph-types imports,inherits,calls
+```
+
+**Search staged 静态图扩展(高级)**:
+
+```bash
+codexlens search --cascade-strategy staged --staged-stage2-mode static_global_graph
+```
+
## FastEmbed 模型配置文件
使用 `fastembed` 后端时的预定义模型:
diff --git a/codex-lens/pyproject.toml b/codex-lens/pyproject.toml
index 2349f43e..c5c85533 100644
--- a/codex-lens/pyproject.toml
+++ b/codex-lens/pyproject.toml
@@ -23,8 +23,8 @@ dependencies = [
"pathspec>=0.11",
"watchdog>=3.0",
# ast-grep for pattern-based AST matching (PyO3 bindings)
- # Note: May have compatibility issues with Python 3.13
- "ast-grep-py>=0.3.0; python_version < '3.13'",
+ # ast-grep-py 0.40+ supports Python 3.13
+ "ast-grep-py>=0.40.0",
]
[project.optional-dependencies]
diff --git a/codex-lens/src/codexlens/cli/commands.py b/codex-lens/src/codexlens/cli/commands.py
index c1ed64cf..8435c299 100644
--- a/codex-lens/src/codexlens/cli/commands.py
+++ b/codex-lens/src/codexlens/cli/commands.py
@@ -126,6 +126,21 @@ def index_init(
no_embeddings: bool = typer.Option(False, "--no-embeddings", help="Skip automatic embedding generation (if semantic deps installed)."),
backend: Optional[str] = typer.Option(None, "--backend", "-b", help="Embedding backend: fastembed (local) or litellm (remote API). Defaults to settings.json config."),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Embedding model: profile name for fastembed or model name for litellm. Defaults to settings.json config."),
+ use_astgrep: Optional[bool] = typer.Option(
+ None,
+ "--use-astgrep/--no-use-astgrep",
+ help="Prefer ast-grep parsers when available (experimental). Overrides settings.json config.",
+ ),
+ static_graph: Optional[bool] = typer.Option(
+ None,
+ "--static-graph/--no-static-graph",
+ help="Persist global relationships during indexing for static graph expansion. Overrides settings.json config.",
+ ),
+ static_graph_types: Optional[str] = typer.Option(
+ None,
+ "--static-graph-types",
+ help="Comma-separated relationship types to persist: imports,inherits,calls. Overrides settings.json config.",
+ ),
max_workers: int = typer.Option(1, "--max-workers", min=1, help="Max concurrent API calls for embedding generation. Recommended: 4-8 for litellm backend."),
json_mode: bool = typer.Option(False, "--json", help="Output JSON response."),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
@@ -154,6 +169,33 @@ def index_init(
# Fallback to settings.json config if CLI params not provided
config.load_settings() # Ensure settings are loaded
+
+ # Apply CLI overrides for parsing/indexing behavior
+ if use_astgrep is not None:
+ config.use_astgrep = bool(use_astgrep)
+ if static_graph is not None:
+ config.static_graph_enabled = bool(static_graph)
+ if static_graph_types is not None:
+ allowed = {"imports", "inherits", "calls"}
+ parsed = [
+ t.strip().lower()
+ for t in static_graph_types.split(",")
+ if t.strip()
+ ]
+ invalid = [t for t in parsed if t not in allowed]
+ if invalid:
+ msg = (
+ "Invalid --static-graph-types. Must be a comma-separated list of: "
+ f"{', '.join(sorted(allowed))}. Got: {invalid}"
+ )
+ if json_mode:
+ print_json(success=False, error=msg)
+ else:
+ console.print(f"[red]Error:[/red] {msg}")
+ raise typer.Exit(code=1)
+ if parsed:
+ config.static_graph_relationship_types = parsed
+
actual_backend = backend or config.embedding_backend
actual_model = model or config.embedding_model
@@ -412,8 +454,10 @@ def watch(
manager: WatcherManager | None = None
try:
+ watch_config = Config.load()
manager = WatcherManager(
root_path=base_path,
+ config=watch_config,
watcher_config=watcher_config,
on_indexed=on_indexed,
)
@@ -459,7 +503,7 @@ def search(
None,
"--staged-stage2-mode",
hidden=True,
- help="[Advanced] Stage 2 expansion mode for cascade strategy 'staged': precomputed | realtime.",
+ help="[Advanced] Stage 2 expansion mode for cascade strategy 'staged': precomputed | realtime | static_global_graph.",
),
# Hidden deprecated parameter for backward compatibility
mode: Optional[str] = typer.Option(None, "--mode", hidden=True, help="[DEPRECATED] Use --method instead."),
@@ -615,8 +659,8 @@ def search(
# Optional staged cascade overrides (only meaningful for cascade strategy 'staged')
if staged_stage2_mode is not None:
stage2 = staged_stage2_mode.strip().lower()
- if stage2 not in {"precomputed", "realtime"}:
- msg = "Invalid --staged-stage2-mode. Must be: precomputed | realtime."
+ if stage2 not in {"precomputed", "realtime", "static_global_graph"}:
+ msg = "Invalid --staged-stage2-mode. Must be: precomputed | realtime | static_global_graph."
if json_mode:
print_json(success=False, error=msg)
else:
@@ -810,7 +854,7 @@ def inspect(
) -> None:
"""Analyze a single file and display symbols."""
_configure_logging(verbose, json_mode)
- config = Config()
+ config = Config.load()
factory = ParserFactory(config)
file_path = file.expanduser().resolve()
@@ -3145,8 +3189,10 @@ def watch(
console.print("[dim]Press Ctrl+C to stop[/dim]\n")
# Create and start watcher manager
+ watch_config = Config.load()
manager = WatcherManager(
root_path=watch_path,
+ config=watch_config,
watcher_config=watcher_config,
on_indexed=lambda result: _display_index_result(result),
)
@@ -3681,7 +3727,7 @@ def index_update(
registry = RegistryStore()
registry.initialize()
mapper = PathMapper()
- config = Config()
+ config = Config.load()
resolved_path = file_path.resolve()
@@ -3776,7 +3822,7 @@ def index_all(
from codexlens.config import Config
from codexlens.storage.index_tree import IndexTreeBuilder
- config = Config()
+ config = Config.load()
languages = _parse_languages(language)
registry = RegistryStore()
registry.initialize()
diff --git a/codex-lens/src/codexlens/config.py b/codex-lens/src/codexlens/config.py
index 84577184..77b5b055 100644
--- a/codex-lens/src/codexlens/config.py
+++ b/codex-lens/src/codexlens/config.py
@@ -294,6 +294,15 @@ class Config:
"timeout_ms": self.llm_timeout_ms,
"batch_size": self.llm_batch_size,
},
+ "parsing": {
+ # Prefer ast-grep processors when available (experimental).
+ "use_astgrep": self.use_astgrep,
+ },
+ "indexing": {
+ # Persist global relationship edges during index build for static graph expansion.
+ "static_graph_enabled": self.static_graph_enabled,
+ "static_graph_relationship_types": self.static_graph_relationship_types,
+ },
"reranker": {
"enabled": self.enable_cross_encoder_rerank,
"backend": self.reranker_backend,
@@ -413,6 +422,34 @@ class Config:
if "fine_k" in cascade:
self.cascade_fine_k = cascade["fine_k"]
+ # Load parsing settings
+ parsing = settings.get("parsing", {})
+ if isinstance(parsing, dict) and "use_astgrep" in parsing:
+ self.use_astgrep = bool(parsing["use_astgrep"])
+
+ # Load indexing settings
+ indexing = settings.get("indexing", {})
+ if isinstance(indexing, dict):
+ if "static_graph_enabled" in indexing:
+ self.static_graph_enabled = bool(indexing["static_graph_enabled"])
+ if "static_graph_relationship_types" in indexing:
+ raw_types = indexing["static_graph_relationship_types"]
+ if isinstance(raw_types, list):
+ allowed = {"imports", "inherits", "calls"}
+ cleaned = []
+ for item in raw_types:
+ val = str(item).strip().lower()
+ if val and val in allowed:
+ cleaned.append(val)
+ if cleaned:
+ self.static_graph_relationship_types = cleaned
+ else:
+ log.warning(
+ "Invalid indexing.static_graph_relationship_types in %s: %r (expected list)",
+ self.settings_path,
+ raw_types,
+ )
+
# Load API settings
api = settings.get("api", {})
if "max_workers" in api:
diff --git a/codex-lens/src/codexlens/parsers/astgrep_processor.py b/codex-lens/src/codexlens/parsers/astgrep_processor.py
index 2fac1cbb..e0358743 100644
--- a/codex-lens/src/codexlens/parsers/astgrep_processor.py
+++ b/codex-lens/src/codexlens/parsers/astgrep_processor.py
@@ -299,12 +299,25 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
if func_name:
all_matches.append((start_line, end_line, "func_def", func_name, node))
- # Get import matches
+ # Get import matches (process import_with_alias first to avoid duplicates)
+ import_alias_positions: set = set()
+
+ # Process import with alias: import X as Y
+ import_alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias"))
+ for node in import_alias_matches:
+ module = self._get_match(node, "MODULE")
+ alias = self._get_match(node, "ALIAS")
+ start_line, end_line = self._get_line_range(node)
+ if module and alias:
+ import_alias_positions.add(start_line)
+ all_matches.append((start_line, end_line, "import_alias", f"{module}:{alias}", node))
+
+ # Process simple imports: import X (skip lines with aliases)
import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt"))
for node in import_matches:
module = self._get_match(node, "MODULE")
start_line, end_line = self._get_line_range(node)
- if module:
+ if module and start_line not in import_alias_positions:
all_matches.append((start_line, end_line, "import", module, node))
from_matches = self.run_ast_grep(source_code, get_pattern("import_from"))
@@ -429,7 +442,7 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
))
elif match_type == "import":
- # Process import statement
+ # Process simple import statement
module = symbol
# Simple import: add base name to alias map
base_name = module.split(".", 1)[0]
@@ -443,6 +456,22 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
source_line=start_line,
))
+ elif match_type == "import_alias":
+ # Process import with alias: import X as Y
+ parts = symbol.split(":", 1)
+ module = parts[0]
+ alias = parts[1] if len(parts) > 1 else ""
+ if alias:
+ update_aliases({alias: module})
+ relationships.append(CodeRelationship(
+ source_symbol=get_current_scope(),
+ target_symbol=module,
+ relationship_type=RelationshipType.IMPORTS,
+ source_file=source_file,
+ target_file=None,
+ source_line=start_line,
+ ))
+
elif match_type == "from_import":
# Process from-import statement
parts = symbol.split(":", 1)
@@ -647,6 +676,22 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
return match.group(1).strip()
return ""
+ def _extract_import_names_from_text(self, import_text: str) -> str:
+ """Extract imported names from from-import statement.
+
+ Args:
+ import_text: Full text of import statement (e.g., "from typing import List, Dict")
+
+ Returns:
+ Names text (e.g., "List, Dict") or empty string
+ """
+ import re
+ # Match "from MODULE import NAMES" - extract NAMES
+ match = re.search(r'from\s+[\w.]+\s+import\s+(.+)$', import_text, re.MULTILINE)
+ if match:
+ return match.group(1).strip()
+ return ""
+
def extract_calls(
self,
source_code: str,
@@ -736,16 +781,19 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
relationships: List[CodeRelationship] = []
alias_map: Dict[str, str] = {}
- # Process simple imports: import X
- import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt"))
- for node in import_matches:
+ # Track processed lines to avoid duplicates
+ processed_lines: set = set()
+
+ # Process import with alias FIRST: import X as Y
+ alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias"))
+ for node in alias_matches:
module = self._get_match(node, "MODULE")
+ alias = self._get_match(node, "ALIAS")
line = self._get_line_number(node)
- if module:
- # Add to alias map: first part of module
- base_name = module.split(".", 1)[0]
- alias_map[base_name] = module
+ if module and alias:
+ alias_map[alias] = module
+ processed_lines.add(line)
relationships.append(CodeRelationship(
source_symbol=source_symbol,
@@ -756,15 +804,16 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
source_line=line,
))
- # Process import with alias: import X as Y
- alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias"))
- for node in alias_matches:
+ # Process simple imports: import X (skip lines already processed)
+ import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt"))
+ for node in import_matches:
module = self._get_match(node, "MODULE")
- alias = self._get_match(node, "ALIAS")
line = self._get_line_number(node)
- if module and alias:
- alias_map[alias] = module
+ if module and line not in processed_lines:
+ # Add to alias map: first part of module
+ base_name = module.split(".", 1)[0]
+ alias_map[base_name] = module
relationships.append(CodeRelationship(
source_symbol=source_symbol,
@@ -779,7 +828,6 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
from_matches = self.run_ast_grep(source_code, get_pattern("import_from"))
for node in from_matches:
module = self._get_match(node, "MODULE")
- names = self._get_match(node, "NAMES")
line = self._get_line_number(node)
if module:
@@ -793,6 +841,10 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
source_line=line,
))
+ # Parse names from node text (ast-grep-py 0.40+ doesn't capture $$$ multi-match)
+ node_text = self._binding._get_node_text(node) if self._binding else ""
+ names = self._extract_import_names_from_text(node_text)
+
# Add aliases for imported names
if names and names != "*":
for name in names.split(","):
diff --git a/codex-lens/src/codexlens/parsers/factory.py b/codex-lens/src/codexlens/parsers/factory.py
index 0f8f4f14..5b07a4bc 100644
--- a/codex-lens/src/codexlens/parsers/factory.py
+++ b/codex-lens/src/codexlens/parsers/factory.py
@@ -24,11 +24,16 @@ class Parser(Protocol):
@dataclass
class SimpleRegexParser:
language_id: str
+ config: Optional[Config] = None
def parse(self, text: str, path: Path) -> IndexedFile:
# Try tree-sitter first for supported languages
if self.language_id in {"python", "javascript", "typescript"}:
- ts_parser = TreeSitterSymbolParser(self.language_id, path)
+ ts_parser = TreeSitterSymbolParser(
+ self.language_id,
+ path,
+ config=self.config,
+ )
if ts_parser.is_available():
indexed = ts_parser.parse(text, path)
if indexed is not None:
@@ -73,7 +78,10 @@ class ParserFactory:
def get_parser(self, language_id: str) -> Parser:
if language_id not in self._parsers:
- self._parsers[language_id] = SimpleRegexParser(language_id)
+ self._parsers[language_id] = SimpleRegexParser(
+ language_id,
+ config=self.config,
+ )
return self._parsers[language_id]
diff --git a/codex-lens/src/codexlens/parsers/treesitter_parser.py b/codex-lens/src/codexlens/parsers/treesitter_parser.py
index 34ef180f..019b6189 100644
--- a/codex-lens/src/codexlens/parsers/treesitter_parser.py
+++ b/codex-lens/src/codexlens/parsers/treesitter_parser.py
@@ -291,7 +291,9 @@ class TreeSitterSymbolParser:
source_file = str(path.resolve())
relationships: List[CodeRelationship] = []
- scope_stack: List[str] = []
+ # Use a synthetic module scope so module-level imports/calls can be recorded
+ # (useful for static global graph persistence).
+ scope_stack: List[str] = [""]
alias_stack: List[Dict[str, str]] = [{}]
def record_import(target_symbol: str, source_line: int) -> None:
@@ -398,7 +400,9 @@ class TreeSitterSymbolParser:
source_file = str(path.resolve())
relationships: List[CodeRelationship] = []
- scope_stack: List[str] = []
+ # Use a synthetic module scope so module-level imports/calls can be recorded
+ # (useful for static global graph persistence).
+ scope_stack: List[str] = [""]
alias_stack: List[Dict[str, str]] = [{}]
def record_import(target_symbol: str, source_line: int) -> None:
diff --git a/codex-lens/src/codexlens/storage/index_tree.py b/codex-lens/src/codexlens/storage/index_tree.py
index 8f61eb74..696ec573 100644
--- a/codex-lens/src/codexlens/storage/index_tree.py
+++ b/codex-lens/src/codexlens/storage/index_tree.py
@@ -519,6 +519,7 @@ class IndexTreeBuilder:
"global_symbol_index_enabled": self.config.global_symbol_index_enabled,
"static_graph_enabled": self.config.static_graph_enabled,
"static_graph_relationship_types": self.config.static_graph_relationship_types,
+ "use_astgrep": getattr(self.config, "use_astgrep", False),
}
worker_args = [
@@ -984,6 +985,7 @@ def _build_dir_worker(args: tuple) -> DirBuildResult:
global_symbol_index_enabled=bool(config_dict.get("global_symbol_index_enabled", True)),
static_graph_enabled=bool(config_dict.get("static_graph_enabled", False)),
static_graph_relationship_types=list(config_dict.get("static_graph_relationship_types", ["imports", "inherits"])),
+ use_astgrep=bool(config_dict.get("use_astgrep", False)),
)
parser_factory = ParserFactory(config)
diff --git a/codex-lens/src/codexlens/watcher/incremental_indexer.py b/codex-lens/src/codexlens/watcher/incremental_indexer.py
index 9991c5fc..39888115 100644
--- a/codex-lens/src/codexlens/watcher/incremental_indexer.py
+++ b/codex-lens/src/codexlens/watcher/incremental_indexer.py
@@ -89,7 +89,18 @@ class IncrementalIndexer:
project_info = self.registry.get_project(source_root)
if project_info:
project_id = project_info.id
- self._global_index = GlobalSymbolIndex(global_db_path, project_id=project_id)
+ try:
+ self._global_index = GlobalSymbolIndex(global_db_path, project_id=project_id)
+ # Ensure schema exists (best-effort). The DB should already be initialized
+ # by `codexlens index init`, but watcher/index-update should be robust.
+ self._global_index.initialize()
+ except Exception as exc:
+ logger.debug(
+ "Failed to initialize global symbol index at %s: %s",
+ global_db_path,
+ exc,
+ )
+ self._global_index = None
return self._global_index
@@ -262,6 +273,34 @@ class IncrementalIndexer:
# Update merkle root
store.update_merkle_root()
+ # Update global relationships for static graph expansion (best-effort).
+ if getattr(self.config, "static_graph_enabled", False):
+ try:
+ source_root = self.mapper.get_project_root(path) or dir_path
+ index_root = self.mapper.source_to_index_dir(source_root)
+ global_index = self._get_global_index(index_root, source_root=source_root)
+ if global_index is not None:
+ allowed_types = set(
+ getattr(
+ self.config,
+ "static_graph_relationship_types",
+ ["imports", "inherits"],
+ )
+ or []
+ )
+ filtered_rels = [
+ r
+ for r in (indexed_file.relationships or [])
+ if r.relationship_type.value in allowed_types
+ ]
+ global_index.update_file_relationships(path, filtered_rels)
+ except Exception as exc:
+ logger.debug(
+ "Failed to update global relationships for %s: %s",
+ path,
+ exc,
+ )
+
logger.debug("Indexed file: %s (%d symbols)", path, len(indexed_file.symbols))
return FileIndexResult(
@@ -329,6 +368,21 @@ class IncrementalIndexer:
try:
store.remove_file(str(path))
store.update_merkle_root()
+
+ # Best-effort cleanup of static graph relationships (keeps global DB consistent).
+ if getattr(self.config, "static_graph_enabled", False):
+ try:
+ source_root = self.mapper.get_project_root(path) or dir_path
+ index_root = self.mapper.source_to_index_dir(source_root)
+ global_index = self._get_global_index(index_root, source_root=source_root)
+ if global_index is not None:
+ global_index.delete_file_relationships(path)
+ except Exception as exc:
+ logger.debug(
+ "Failed to delete global relationships for %s: %s",
+ path,
+ exc,
+ )
logger.debug("Removed file from index: %s", path)
return True
diff --git a/codex-lens/tests/test_parsers.py b/codex-lens/tests/test_parsers.py
index a8fc3e04..9651fddc 100644
--- a/codex-lens/tests/test_parsers.py
+++ b/codex-lens/tests/test_parsers.py
@@ -377,6 +377,43 @@ class TestParserFactory:
finally:
del os.environ["CODEXLENS_DATA_DIR"]
+ def test_factory_passes_config_to_treesitter(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Ensure ParserFactory config is forwarded into TreeSitterSymbolParser."""
+ from codexlens.entities import IndexedFile
+
+ captured: dict = {}
+
+ class FakeTreeSitterSymbolParser:
+ def __init__(self, language_id, path=None, config=None) -> None:
+ captured["config"] = config
+ self.language_id = language_id
+
+ def is_available(self) -> bool:
+ return True
+
+ def parse(self, text: str, path: Path) -> IndexedFile:
+ return IndexedFile(
+ path=str(path.resolve()),
+ language=self.language_id,
+ symbols=[],
+ chunks=[],
+ relationships=[],
+ )
+
+ monkeypatch.setattr(
+ "codexlens.parsers.factory.TreeSitterSymbolParser",
+ FakeTreeSitterSymbolParser,
+ )
+
+ config = Config()
+ config.use_astgrep = True
+
+ factory = ParserFactory(config)
+ parser = factory.get_parser("python")
+ parser.parse("def hello():\n pass\n", Path("test.py"))
+
+ assert captured.get("config") is config
+
class TestParserEdgeCases:
"""Edge case tests for parsers."""